├── .bowerrc ├── .coveragerc ├── .dockerignore ├── .github └── issue_template.md ├── .gitignore ├── .travis.yml ├── CHECKLIST-Release.md ├── CONTRIBUTING.md ├── COPYING.md ├── Dockerfile ├── MANIFEST.in ├── README.md ├── bower.json ├── circle.yml ├── dev-requirements.txt ├── docs ├── Makefile ├── environment.yml ├── make.bat ├── package.json ├── requirements.txt ├── rest-api.yml └── source │ ├── api │ ├── auth.rst │ ├── index.rst │ ├── services.auth.rst │ ├── spawner.rst │ └── user.rst │ ├── authenticators.md │ ├── changelog.md │ ├── conf.py │ ├── config-examples.md │ ├── contributor-list.md │ ├── getting-started.md │ ├── howitworks.md │ ├── images │ ├── hub-pieces.png │ ├── jhub-parts.png │ └── spawn-form.png │ ├── index.rst │ ├── quickstart.md │ ├── rest.md │ ├── services.md │ ├── spawners.md │ ├── spelling_wordlist.txt │ ├── troubleshooting.md │ ├── upgrading.md │ └── websecurity.md ├── examples ├── cull-idle │ ├── README.md │ ├── cull_idle_servers.py │ └── jupyterhub_config.py ├── postgres │ ├── README.md │ ├── db │ │ ├── Dockerfile │ │ └── initdb.sh │ └── hub │ │ ├── Dockerfile │ │ └── jupyterhub_config.py ├── service-whoami-flask │ ├── README.md │ ├── jupyterhub_config.py │ ├── launch.sh │ ├── whoami-flask.py │ ├── whoami.png │ └── whoami.py ├── service-whoami │ ├── README.md │ ├── jupyterhub_config.py │ ├── whoami.png │ └── whoami.py └── spawn-form │ └── jupyterhub_config.py ├── jupyterhub ├── __init__.py ├── __main__.py ├── _data.py ├── alembic.ini ├── alembic │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── 19c0846f6344_base_revision_for_0_5.py │ │ ├── af4cbdb2d13c_services.py │ │ └── eeb276e51423_auth_state.py ├── apihandlers │ ├── __init__.py │ ├── auth.py │ ├── base.py │ ├── groups.py │ ├── hub.py │ ├── proxy.py │ ├── services.py │ └── users.py ├── app.py ├── auth.py ├── dbutil.py ├── emptyclass.py ├── handlers │ ├── __init__.py │ ├── base.py │ ├── login.py │ ├── pages.py │ └── static.py ├── log.py ├── orm.py ├── services │ ├── __init__.py │ ├── auth.py │ └── service.py ├── singleuser.py ├── spawner.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── mocking.py │ ├── mockservice.py │ ├── mocksu.py │ ├── old-jupyterhub.sqlite │ ├── test_api.py │ ├── test_app.py │ ├── test_auth.py │ ├── test_db.py │ ├── test_orm.py │ ├── test_pages.py │ ├── test_proxy.py │ ├── test_services.py │ ├── test_services_auth.py │ ├── test_singleuser.py │ ├── test_spawner.py │ └── test_traitlets.py ├── traitlets.py ├── user.py ├── utils.py └── version.py ├── onbuild ├── Dockerfile └── README.md ├── package.json ├── readthedocs.yml ├── requirements.txt ├── scripts ├── jupyterhub └── jupyterhub-singleuser ├── setup.py ├── setupegg.py ├── share └── jupyter │ └── hub │ ├── static │ ├── favicon.ico │ ├── images │ │ ├── jupyter.png │ │ └── jupyterhub-80.png │ ├── js │ │ ├── admin.js │ │ ├── home.js │ │ ├── jhapi.js │ │ └── utils.js │ └── less │ │ ├── admin.less │ │ ├── error.less │ │ ├── login.less │ │ ├── page.less │ │ ├── style.less │ │ └── variables.less │ └── templates │ ├── 404.html │ ├── admin.html │ ├── error.html │ ├── home.html │ ├── login.html │ ├── page.html │ ├── spawn.html │ └── spawn_pending.html └── tools └── tasks.py /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "share/jupyter/hub/static/components" 3 | } -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | jupyterhub/tests/* 4 | jupyterhub/alembic/* 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | examples 2 | bench 3 | jupyterhub_cookie_secret 4 | jupyterhub.sqlite 5 | jupyterhub_config.py 6 | node_modules 7 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | Hi! Thanks for using JupyterHub. 2 | 3 | If you are reporting an issue with JupyterHub: 4 | 5 | - Please use the [GitHub issue](https://github.com/jupyterhub/jupyterhub/issues) 6 | search feature to check if your issue has been asked already. If it has, 7 | please add your comments to the existing issue. 8 | 9 | - Where applicable, please fill out the details below to help us troubleshoot 10 | the issue that you are facing. Please be as thorough as you are able to 11 | provide details on the issue. 12 | 13 | **How to reproduce the issue** 14 | 15 | **What you expected to happen** 16 | 17 | **What actually happens** 18 | 19 | **Share what version of JupyterHub you are using** 20 | 21 | Running `jupyter troubleshoot` from the command line, if possible, and posting 22 | its output would also be helpful. 23 | 24 | ``` 25 | 26 | Insert jupyter troubleshoot output here 27 | 28 | 29 | ``` 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.py[co] 3 | *~ 4 | .cache 5 | .DS_Store 6 | build 7 | dist 8 | docs/_build 9 | docs/source/_static/rest-api 10 | .ipynb_checkpoints 11 | # ignore config file at the top-level of the repo 12 | # but not sub-dirs 13 | /jupyterhub_config.py 14 | jupyterhub_cookie_secret 15 | jupyterhub.sqlite 16 | share/jupyter/hub/static/components 17 | share/jupyter/hub/static/css/style.min.css 18 | share/jupyter/hub/static/css/style.min.css.map 19 | *.egg-info 20 | MANIFEST 21 | .coverage 22 | htmlcov 23 | 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # http://travis-ci.org/#!/jupyter/jupyterhub 2 | language: python 3 | sudo: false 4 | python: 5 | - 3.6-dev 6 | - 3.5 7 | - 3.4 8 | - 3.3 9 | before_install: 10 | - npm install 11 | - npm install -g configurable-http-proxy 12 | - git clone --quiet --depth 1 https://github.com/minrk/travis-wheels travis-wheels 13 | install: 14 | - pip install --pre -f travis-wheels/wheelhouse -r dev-requirements.txt . 15 | script: 16 | - travis_retry py.test --cov jupyterhub jupyterhub/tests -v 17 | after_success: 18 | - codecov 19 | matrix: 20 | include: 21 | - python: 3.5 22 | env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://127.0.0.1.xip.io:8000 23 | -------------------------------------------------------------------------------- /CHECKLIST-Release.md: -------------------------------------------------------------------------------- 1 | # Release checklist 2 | 3 | - [ ] Upgrade Docs prior to Release 4 | 5 | - [ ] Change log 6 | - [ ] New features documented 7 | - [ ] Update the contributor list - thank you page 8 | 9 | - [ ] Upgrade and test Reference Deployments 10 | 11 | - [ ] Release software 12 | 13 | - [ ] Make sure 0 issues in milestone 14 | - [ ] Follow release process steps 15 | - [ ] Send builds to PyPI (Warehouse) and Conda Forge 16 | 17 | - [ ] Blog post and/or release note 18 | 19 | - [ ] Notify users of release 20 | 21 | - [ ] Email Jupyter and Jupyter In Education mailing lists 22 | - [ ] Tweet (optional) 23 | 24 | - [ ] Increment the version number for the next release 25 | 26 | - [ ] Update roadmap 27 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We mainly follow the [IPython Contributing Guide](https://github.com/ipython/ipython/blob/master/CONTRIBUTING.md). 4 | -------------------------------------------------------------------------------- /COPYING.md: -------------------------------------------------------------------------------- 1 | # The Jupyter multi-user notebook server licensing terms 2 | 3 | Jupyter multi-user notebook server is licensed under the terms of the Modified BSD License 4 | (also known as New or Revised or 3-Clause BSD), as follows: 5 | 6 | - Copyright (c) 2014-, Jupyter Development Team 7 | 8 | All rights reserved. 9 | 10 | Redistribution and use in source and binary forms, with or without 11 | modification, are permitted provided that the following conditions are met: 12 | 13 | Redistributions of source code must retain the above copyright notice, this 14 | list of conditions and the following disclaimer. 15 | 16 | Redistributions in binary form must reproduce the above copyright notice, this 17 | list of conditions and the following disclaimer in the documentation and/or 18 | other materials provided with the distribution. 19 | 20 | Neither the name of the Jupyter Development Team nor the names of its 21 | contributors may be used to endorse or promote products derived from this 22 | software without specific prior written permission. 23 | 24 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 25 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 26 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 27 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE 28 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 29 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 30 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 31 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 32 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 33 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 | 35 | ## About the Jupyter Development Team 36 | 37 | The Jupyter Development Team is the set of all contributors to the Jupyter project. 38 | This includes all of the Jupyter subprojects. 39 | 40 | The core team that coordinates development on GitHub can be found here: 41 | https://github.com/jupyter/. 42 | 43 | ## Our Copyright Policy 44 | 45 | Jupyter uses a shared copyright model. Each contributor maintains copyright 46 | over their contributions to Jupyter. But, it is important to note that these 47 | contributions are typically only changes to the repositories. Thus, the Jupyter 48 | source code, in its entirety is not the copyright of any single person or 49 | institution. Instead, it is the collective copyright of the entire Jupyter 50 | Development Team. If individual contributors want to maintain a record of what 51 | changes/contributions they have specific copyright on, they should indicate 52 | their copyright in the commit message of the change, when they commit the 53 | change to one of the Jupyter repositories. 54 | 55 | With this in mind, the following banner should be used in any source code file 56 | to indicate the copyright and license terms: 57 | 58 | # Copyright (c) Jupyter Development Team. 59 | # Distributed under the terms of the Modified BSD License. 60 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # An incomplete base Docker image for running JupyterHub 2 | # 3 | # Add your configuration to create a complete derivative Docker image. 4 | # 5 | # Include your configuration settings by starting with one of two options: 6 | # 7 | # Option 1: 8 | # 9 | # FROM jupyterhub/jupyterhub:latest 10 | # 11 | # And put your configuration file jupyterhub_config.py in /srv/jupyterhub/jupyterhub_config.py. 12 | # 13 | # Option 2: 14 | # 15 | # Or you can create your jupyterhub config and database on the host machine, and mount it with: 16 | # 17 | # docker run -v $PWD:/srv/jupyterhub -t jupyterhub/jupyterhub 18 | # 19 | # NOTE 20 | # If you base on jupyterhub/jupyterhub-onbuild 21 | # your jupyterhub_config.py will be added automatically 22 | # from your docker directory. 23 | 24 | FROM debian:jessie 25 | MAINTAINER Jupyter Project 26 | 27 | # install nodejs, utf8 locale, set CDN because default httpredir is unreliable 28 | ENV DEBIAN_FRONTEND noninteractive 29 | RUN REPO=http://cdn-fastly.deb.debian.org && \ 30 | echo "deb $REPO/debian jessie main\ndeb $REPO/debian-security jessie/updates main" > /etc/apt/sources.list && \ 31 | apt-get -y update && \ 32 | apt-get -y upgrade && \ 33 | apt-get -y install wget locales git bzip2 &&\ 34 | /usr/sbin/update-locale LANG=C.UTF-8 && \ 35 | locale-gen C.UTF-8 && \ 36 | apt-get remove -y locales && \ 37 | apt-get clean && \ 38 | rm -rf /var/lib/apt/lists/* 39 | ENV LANG C.UTF-8 40 | 41 | # install Python + NodeJS with conda 42 | RUN wget -q https://repo.continuum.io/miniconda/Miniconda3-4.2.12-Linux-x86_64.sh -O /tmp/miniconda.sh && \ 43 | echo 'd0c7c71cc5659e54ab51f2005a8d96f3 */tmp/miniconda.sh' | md5sum -c - && \ 44 | bash /tmp/miniconda.sh -f -b -p /opt/conda && \ 45 | /opt/conda/bin/conda install --yes -c conda-forge python=3.5 sqlalchemy tornado jinja2 traitlets requests pip nodejs configurable-http-proxy && \ 46 | /opt/conda/bin/pip install --upgrade pip && \ 47 | rm /tmp/miniconda.sh 48 | ENV PATH=/opt/conda/bin:$PATH 49 | 50 | ADD . /src/jupyterhub 51 | WORKDIR /src/jupyterhub 52 | 53 | RUN python setup.py js && pip install . && \ 54 | rm -rf $PWD ~/.cache ~/.npm 55 | 56 | RUN mkdir -p /srv/jupyterhub/ 57 | WORKDIR /srv/jupyterhub/ 58 | EXPOSE 8000 59 | 60 | LABEL org.jupyter.service="jupyterhub" 61 | 62 | CMD ["jupyterhub"] 63 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include COPYING.md 3 | include setupegg.py 4 | include bower.json 5 | include package.json 6 | include *requirements.txt 7 | include Dockerfile 8 | 9 | graft onbuild 10 | graft jupyterhub 11 | graft scripts 12 | graft share 13 | 14 | # Documentation 15 | graft docs 16 | prune docs/node_modules 17 | 18 | # prune some large unused files from components 19 | prune share/jupyter/hub/static/components/bootstrap/css 20 | exclude share/jupyter/hub/static/components/components/fonts/*.svg 21 | exclude share/jupyter/hub/static/components/bootstrap/less/*.js 22 | exclude share/jupyter/hub/static/components/font-awesome/css 23 | exclude share/jupyter/hub/static/components/font-awesome/fonts/*.svg 24 | exclude share/jupyter/hub/static/components/jquery/*migrate*.js 25 | prune share/jupyter/hub/static/components/moment/lang 26 | prune share/jupyter/hub/static/components/moment/min 27 | 28 | # Patterns to exclude from any directory 29 | global-exclude *~ 30 | global-exclude *.pyc 31 | global-exclude *.pyo 32 | global-exclude .git 33 | global-exclude .ipynb_checkpoints 34 | global-exclude .bower.json 35 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyterhub-deps", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "bootstrap": "components/bootstrap#~3.1", 6 | "font-awesome": "components/font-awesome#~4.1", 7 | "jquery": "components/jquery#~2.0", 8 | "moment": "~2.7", 9 | "requirejs": "~2.1" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | services: 3 | - docker 4 | 5 | dependencies: 6 | override: 7 | - ls 8 | 9 | test: 10 | override: 11 | - docker build -t jupyterhub/jupyterhub . 12 | - docker build -t jupyterhub/jupyterhub-onbuild:${CIRCLE_TAG:-latest} onbuild 13 | 14 | deployment: 15 | hub: 16 | branch: master 17 | commands: 18 | - docker login -u $DOCKER_USER -p $DOCKER_PASS -e unused@example.com 19 | - docker push jupyterhub/jupyterhub-onbuild 20 | release: 21 | tag: /.*/ 22 | commands: 23 | - docker login -u $DOCKER_USER -p $DOCKER_PASS -e unused@example.com 24 | - docker push jupyterhub/jupyterhub-onbuild:$CIRCLE_TAG 25 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | mock 3 | codecov 4 | pytest-cov 5 | pytest>=2.8 6 | notebook 7 | requests-mock 8 | -------------------------------------------------------------------------------- /docs/environment.yml: -------------------------------------------------------------------------------- 1 | name: jhub_docs 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - nodejs 6 | - python=3 7 | - jinja2 8 | - pamela 9 | - requests 10 | - sqlalchemy>=1 11 | - tornado>=4.1 12 | - traitlets>=4.1 13 | - sphinx>=1.3.6 14 | - sphinx_rtd_theme 15 | - pip: 16 | - recommonmark==0.4.0 -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyterhub-docs-build", 3 | "version": "0.0.0", 4 | "description": "build JupyterHub swagger docs", 5 | "scripts": { 6 | "rest-api": "bootprint openapi ./rest-api.yml source/_static/rest-api" 7 | }, 8 | "author": "", 9 | "license": "BSD-3-Clause", 10 | "devDependencies": { 11 | "bootprint": "^0.10.0", 12 | "bootprint-openapi": "^0.17.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ../requirements.txt 2 | sphinx>=1.3.6 3 | recommonmark==0.4.0 -------------------------------------------------------------------------------- /docs/source/api/auth.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Authenticators 3 | ============== 4 | 5 | Module: :mod:`jupyterhub.auth` 6 | ============================== 7 | 8 | .. automodule:: jupyterhub.auth 9 | 10 | .. currentmodule:: jupyterhub.auth 11 | 12 | 13 | 14 | .. autoclass:: Authenticator 15 | :members: 16 | 17 | .. autoclass:: LocalAuthenticator 18 | :members: 19 | 20 | .. autoclass:: PAMAuthenticator 21 | 22 | -------------------------------------------------------------------------------- /docs/source/api/index.rst: -------------------------------------------------------------------------------- 1 | .. _api-index: 2 | 3 | #################### 4 | The JupyterHub API 5 | #################### 6 | 7 | :Release: |release| 8 | :Date: |today| 9 | 10 | JupyterHub also provides a REST API for administration of the Hub and users. 11 | The documentation on `Using JupyterHub's REST API <../rest.html>`_ provides 12 | information on: 13 | 14 | - Creating an API token 15 | - Adding tokens to the configuration file (optional) 16 | - Making an API request 17 | 18 | The same JupyterHub API spec, as found here, is available in an interactive form 19 | `here (on swagger's petstore) `__. 20 | The `OpenAPI Initiative`_ (fka Swagger™) is a project used to describe 21 | and document RESTful APIs. 22 | 23 | JupyterHub API Reference: 24 | 25 | .. toctree:: 26 | 27 | auth 28 | spawner 29 | user 30 | services.auth 31 | 32 | 33 | .. _OpenAPI Initiative: https://www.openapis.org/ 34 | -------------------------------------------------------------------------------- /docs/source/api/services.auth.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | Authenticating Services 3 | ======================= 4 | 5 | Module: :mod:`jupyterhub.services.auth` 6 | ======================================= 7 | 8 | .. automodule:: jupyterhub.services.auth 9 | 10 | .. currentmodule:: jupyterhub.services.auth 11 | 12 | 13 | .. autoclass:: HubAuth 14 | :members: 15 | 16 | .. autoclass:: HubAuthenticated 17 | :members: 18 | 19 | -------------------------------------------------------------------------------- /docs/source/api/spawner.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Spawners 3 | ============== 4 | 5 | Module: :mod:`jupyterhub.spawner` 6 | ================================= 7 | 8 | .. automodule:: jupyterhub.spawner 9 | 10 | .. currentmodule:: jupyterhub.spawner 11 | 12 | :class:`Spawner` 13 | ---------------- 14 | 15 | .. autoclass:: Spawner 16 | :members: options_from_form, poll, start, stop, get_args, get_env, get_state, template_namespace, format_string 17 | 18 | .. autoclass:: LocalProcessSpawner 19 | -------------------------------------------------------------------------------- /docs/source/api/user.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Users 3 | ============= 4 | 5 | Module: :mod:`jupyterhub.user` 6 | ============================== 7 | 8 | .. automodule:: jupyterhub.user 9 | 10 | .. currentmodule:: jupyterhub.user 11 | 12 | :class:`User` 13 | ------------- 14 | 15 | .. class:: Server 16 | 17 | .. autoclass:: User 18 | :members: escaped_name 19 | 20 | .. attribute:: name 21 | 22 | The user's name 23 | 24 | .. attribute:: server 25 | 26 | The user's Server data object if running, None otherwise. 27 | Has ``ip``, ``port`` attributes. 28 | 29 | .. attribute:: spawner 30 | 31 | The user's :class:`~.Spawner` instance. 32 | -------------------------------------------------------------------------------- /docs/source/authenticators.md: -------------------------------------------------------------------------------- 1 | # Authenticators 2 | 3 | The [Authenticator][] is the mechanism for authorizing users. 4 | Basic authenticators use simple username and password authentication. 5 | JupyterHub ships only with a [PAM][]-based Authenticator, 6 | for logging in with local user accounts. 7 | 8 | You can use custom Authenticator subclasses to enable authentication via other systems. 9 | One such example is using [GitHub OAuth][]. 10 | 11 | Because the username is passed from the Authenticator to the Spawner, 12 | a custom Authenticator and Spawner are often used together. 13 | 14 | See a list of custom Authenticators [on the wiki](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators). 15 | 16 | 17 | ## Basics of Authenticators 18 | 19 | A basic Authenticator has one central method: 20 | 21 | ### Authenticator.authenticate 22 | 23 | Authenticator.authenticate(handler, data) 24 | 25 | This method is passed the tornado RequestHandler and the POST data from the login form. 26 | Unless the login form has been customized, `data` will have two keys: 27 | 28 | - `username` (self-explanatory) 29 | - `password` (also self-explanatory) 30 | 31 | `authenticate`'s job is simple: 32 | 33 | - return a username (non-empty str) 34 | of the authenticated user if authentication is successful 35 | - return `None` otherwise 36 | 37 | Writing an Authenticator that looks up passwords in a dictionary 38 | requires only overriding this one method: 39 | 40 | ```python 41 | from tornado import gen 42 | from IPython.utils.traitlets import Dict 43 | from jupyterhub.auth import Authenticator 44 | 45 | class DictionaryAuthenticator(Authenticator): 46 | 47 | passwords = Dict(config=True, 48 | help="""dict of username:password for authentication""" 49 | ) 50 | 51 | @gen.coroutine 52 | def authenticate(self, handler, data): 53 | if self.passwords.get(data['username']) == data['password']: 54 | return data['username'] 55 | ``` 56 | 57 | ### Authenticator.whitelist 58 | 59 | Authenticators can specify a whitelist of usernames to allow authentication. 60 | For local user authentication (e.g. PAM), this lets you limit which users 61 | can login. 62 | 63 | 64 | ## Normalizing and validating usernames 65 | 66 | Since the Authenticator and Spawner both use the same username, 67 | sometimes you want to transform the name coming from the authentication service 68 | (e.g. turning email addresses into local system usernames) before adding them to the Hub service. 69 | Authenticators can define `normalize_username`, which takes a username. 70 | The default normalization is to cast names to lowercase 71 | 72 | For simple mappings, a configurable dict `Authenticator.username_map` is used to turn one name into another: 73 | 74 | ```python 75 | c.Authenticator.username_map = { 76 | 'service-name': 'localname' 77 | } 78 | ``` 79 | 80 | ### Validating usernames 81 | 82 | In most cases, there is a very limited set of acceptable usernames. 83 | Authenticators can define `validate_username(username)`, 84 | which should return True for a valid username and False for an invalid one. 85 | The primary effect this has is improving error messages during user creation. 86 | 87 | The default behavior is to use configurable `Authenticator.username_pattern`, 88 | which is a regular expression string for validation. 89 | 90 | To only allow usernames that start with 'w': 91 | 92 | ```python 93 | c.Authenticator.username_pattern = r'w.*' 94 | ``` 95 | 96 | ## OAuth and other non-password logins 97 | 98 | Some login mechanisms, such as [OAuth][], don't map onto username+password. 99 | For these, you can override the login handlers. 100 | 101 | You can see an example implementation of an Authenticator that uses [GitHub OAuth][] 102 | at [OAuthenticator][]. 103 | 104 | 105 | ## Writing a custom authenticator 106 | 107 | If you are interested in writing a custom authenticator, you can read [this tutorial](http://jupyterhub-tutorial.readthedocs.io/en/latest/authenticators.html). 108 | 109 | [Authenticator]: https://github.com/jupyterhub/jupyterhub/blob/master/jupyterhub/auth.py 110 | [PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module 111 | [OAuth]: https://en.wikipedia.org/wiki/OAuth 112 | [GitHub OAuth]: https://developer.github.com/v3/oauth/ 113 | [OAuthenticator]: https://github.com/jupyterhub/oauthenticator 114 | -------------------------------------------------------------------------------- /docs/source/changelog.md: -------------------------------------------------------------------------------- 1 | # Change log summary 2 | 3 | For detailed changes from the prior release, click on the version number, and 4 | its link will bring up a GitHub listing of changes. Use `git log` on the 5 | command line for details. 6 | 7 | 8 | ## [Unreleased] 0.8 9 | 10 | ## 0.7 11 | 12 | ### [0.7.0] - 2016-12-2 13 | 14 | #### Added 15 | 16 | - Implement Services API [\#705](https://github.com/jupyterhub/jupyterhub/pull/705) 17 | - Add `/api/` and `/api/info` endpoints [\#675](https://github.com/jupyterhub/jupyterhub/pull/675) 18 | - Add documentation for JupyterLab, pySpark configuration, troubleshooting, 19 | and more. 20 | - Add logging of error if adding users already in database. [\#689](https://github.com/jupyterhub/jupyterhub/pull/689) 21 | - Add HubAuth class for authenticating with JupyterHub. This class can 22 | be used by any application, even outside tornado. 23 | - Add user groups. 24 | - Add `/hub/user-redirect/...` URL for redirecting users to a file on their own server. 25 | 26 | 27 | #### Changed 28 | 29 | - Always install with setuptools but not eggs (effectively require 30 | `pip install .`) [\#722](https://github.com/jupyterhub/jupyterhub/pull/722) 31 | - Updated formatting of changelog. [\#711](https://github.com/jupyterhub/jupyterhub/pull/711) 32 | - Single-user server is provided by JupyterHub package, so single-user servers depend on JupyterHub now. 33 | 34 | #### Fixed 35 | 36 | - Fix docker repository location [\#719](https://github.com/jupyterhub/jupyterhub/pull/719) 37 | - Fix swagger spec conformance and timestamp type in API spec 38 | - Various redirect-loop-causing bugs have been fixed. 39 | 40 | 41 | #### Removed 42 | 43 | - Deprecate `--no-ssl` command line option. It has no meaning and warns if 44 | used. [\#789](https://github.com/jupyterhub/jupyterhub/pull/789) 45 | - Deprecate `%U` username substitution in favor of `{username}`. [\#748](https://github.com/jupyterhub/jupyterhub/pull/748) 46 | - Removed deprecated SwarmSpawner link. [\#699](https://github.com/jupyterhub/jupyterhub/pull/699) 47 | 48 | ## 0.6 49 | 50 | ### [0.6.1] - 2016-05-04 51 | 52 | Bugfixes on 0.6: 53 | 54 | - statsd is an optional dependency, only needed if in use 55 | - Notice more quickly when servers have crashed 56 | - Better error pages for proxy errors 57 | - Add Stop All button to admin panel for stopping all servers at once 58 | 59 | ### [0.6.0] - 2016-04-25 60 | 61 | - JupyterHub has moved to a new `jupyterhub` namespace on GitHub and Docker. What was `juptyer/jupyterhub` is now `jupyterhub/jupyterhub`, etc. 62 | - `jupyterhub/jupyterhub` image on DockerHub no longer loads the jupyterhub_config.py in an ONBUILD step. A new `jupyterhub/jupyterhub-onbuild` image does this 63 | - Add statsd support, via `c.JupyterHub.statsd_{host,port,prefix}` 64 | - Update to traitlets 4.1 `@default`, `@observe` APIs for traits 65 | - 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 66 | - Add `Spawner.environment` configurable, for defining extra environment variables to load for single-user servers 67 | - JupyterHub API tokens can be pregenerated and loaded via `JupyterHub.api_tokens`, a dict of `token: username`. 68 | - JupyterHub API tokens can be requested via the REST API, with a POST request to `/api/authorizations/token`. 69 | This can only be used if the Authenticator has a username and password. 70 | - Various fixes for user URLs and redirects 71 | 72 | 73 | ## [0.5] - 2016-03-07 74 | 75 | 76 | - Single-user server must be run with Jupyter Notebook ≥ 4.0 77 | - Require `--no-ssl` confirmation to allow the Hub to be run without SSL (e.g. behind SSL termination in nginx) 78 | - Add lengths to text fields for MySQL support 79 | - Add `Spawner.disable_user_config` for preventing user-owned configuration from modifying single-user servers. 80 | - Fixes for MySQL support. 81 | - 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]'`. 82 | - Use `127.0.0.1` for local communication instead of `localhost`, avoiding issues with DNS on some systems. 83 | - Fix race that could add users to proxy prematurely if spawning is slow. 84 | 85 | ## 0.4 86 | 87 | ### [0.4.1] - 2016-02-03 88 | 89 | Fix removal of `/login` page in 0.4.0, breaking some OAuth providers. 90 | 91 | ### [0.4.0] - 2016-02-01 92 | 93 | - Add `Spawner.user_options_form` for specifying an HTML form to present to users, 94 | allowing users to influence the spawning of their own servers. 95 | - Add `Authenticator.pre_spawn_start` and `Authenticator.post_spawn_stop` hooks, 96 | so that Authenticators can do setup or teardown (e.g. passing credentials to Spawner, 97 | mounting data sources, etc.). 98 | These methods are typically used with custom Authenticator+Spawner pairs. 99 | - 0.4 will be the last JupyterHub release where single-user servers running IPython 3 is supported instead of Notebook ≥ 4.0. 100 | 101 | 102 | ## [0.3] - 2015-11-04 103 | 104 | - No longer make the user starting the Hub an admin 105 | - start PAM sessions on login 106 | - hooks for Authenticators to fire before spawners start and after they stop, 107 | allowing deeper interaction between Spawner/Authenticator pairs. 108 | - login redirect fixes 109 | 110 | ## [0.2] - 2015-07-12 111 | 112 | - Based on standalone traitlets instead of IPython.utils.traitlets 113 | - multiple users in admin panel 114 | - Fixes for usernames that require escaping 115 | 116 | ## 0.1 - 2015-03-07 117 | 118 | First preview release 119 | 120 | 121 | [Unreleased]: https://github.com/jupyterhub/jupyterhub/compare/0.7.0...HEAD 122 | [0.7.0]: https://github.com/jupyterhub/jupyterhub/compare/0.6.1...0.7.0 123 | [0.6.1]: https://github.com/jupyterhub/jupyterhub/compare/0.6.0...0.6.1 124 | [0.6.0]: https://github.com/jupyterhub/jupyterhub/compare/0.5.0...0.6.0 125 | [0.5]: https://github.com/jupyterhub/jupyterhub/compare/0.4.1...0.5.0 126 | [0.4.1]: https://github.com/jupyterhub/jupyterhub/compare/0.4.0...0.4.1 127 | [0.4.0]: https://github.com/jupyterhub/jupyterhub/compare/0.3.0...0.4.0 128 | [0.3]: https://github.com/jupyterhub/jupyterhub/compare/0.2.0...0.3.0 129 | [0.2]: https://github.com/jupyterhub/jupyterhub/compare/0.1.0...0.2.0 130 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | import sys 4 | import os 5 | import shlex 6 | 7 | # For conversion from markdown to html 8 | import recommonmark.parser 9 | 10 | # Set paths 11 | #sys.path.insert(0, os.path.abspath('.')) 12 | 13 | # -- General configuration ------------------------------------------------ 14 | 15 | # Minimal Sphinx version 16 | needs_sphinx = '1.4' 17 | 18 | # Sphinx extension modules 19 | extensions = [ 20 | 'sphinx.ext.autodoc', 21 | 'sphinx.ext.intersphinx', 22 | 'sphinx.ext.napoleon', 23 | ] 24 | 25 | templates_path = ['_templates'] 26 | 27 | # The master toctree document. 28 | master_doc = 'index' 29 | 30 | # General information about the project. 31 | project = u'JupyterHub' 32 | copyright = u'2016, Project Jupyter team' 33 | author = u'Project Jupyter team' 34 | 35 | # Autopopulate version 36 | from os.path import dirname 37 | docs = dirname(dirname(__file__)) 38 | root = dirname(docs) 39 | sys.path.insert(0, root) 40 | 41 | import jupyterhub 42 | # The short X.Y version. 43 | version = '%i.%i' % jupyterhub.version_info[:2] 44 | # The full version, including alpha/beta/rc tags. 45 | release = jupyterhub.__version__ 46 | 47 | language = None 48 | exclude_patterns = [] 49 | pygments_style = 'sphinx' 50 | todo_include_todos = False 51 | 52 | # -- Source ------------------------------------------------------------- 53 | 54 | source_parsers = { 55 | '.md': 'recommonmark.parser.CommonMarkParser', 56 | } 57 | 58 | source_suffix = ['.rst', '.md'] 59 | #source_encoding = 'utf-8-sig' 60 | 61 | # -- Options for HTML output ---------------------------------------------- 62 | 63 | # The theme to use for HTML and HTML Help pages. 64 | html_theme = 'sphinx_rtd_theme' 65 | 66 | #html_theme_options = {} 67 | #html_theme_path = [] 68 | #html_title = None 69 | #html_short_title = None 70 | #html_logo = None 71 | #html_favicon = None 72 | 73 | # Paths that contain custom static files (such as style sheets) 74 | html_static_path = ['_static'] 75 | 76 | #html_extra_path = [] 77 | #html_last_updated_fmt = '%b %d, %Y' 78 | #html_use_smartypants = True 79 | #html_sidebars = {} 80 | #html_additional_pages = {} 81 | #html_domain_indices = True 82 | #html_use_index = True 83 | #html_split_index = False 84 | #html_show_sourcelink = True 85 | #html_show_sphinx = True 86 | #html_show_copyright = True 87 | #html_use_opensearch = '' 88 | #html_file_suffix = None 89 | #html_search_language = 'en' 90 | #html_search_options = {'type': 'default'} 91 | #html_search_scorer = 'scorer.js' 92 | htmlhelp_basename = 'JupyterHubdoc' 93 | 94 | # -- Options for LaTeX output --------------------------------------------- 95 | 96 | latex_elements = { 97 | #'papersize': 'letterpaper', 98 | #'pointsize': '10pt', 99 | #'preamble': '', 100 | #'figure_align': 'htbp', 101 | } 102 | 103 | # Grouping the document tree into LaTeX files. List of tuples 104 | # (source start file, target name, title, 105 | # author, documentclass [howto, manual, or own class]). 106 | latex_documents = [ 107 | (master_doc, 'JupyterHub.tex', u'JupyterHub Documentation', 108 | u'Project Jupyter team', 'manual'), 109 | ] 110 | 111 | #latex_logo = None 112 | #latex_use_parts = False 113 | #latex_show_pagerefs = False 114 | #latex_show_urls = False 115 | #latex_appendices = [] 116 | #latex_domain_indices = True 117 | 118 | 119 | # -- manual page output ------------------------------------------------- 120 | 121 | # One entry per manual page. List of tuples 122 | # (source start file, name, description, authors, manual section). 123 | man_pages = [ 124 | (master_doc, 'jupyterhub', u'JupyterHub Documentation', 125 | [author], 1) 126 | ] 127 | 128 | #man_show_urls = False 129 | 130 | 131 | # -- Texinfo output ----------------------------------------------------- 132 | 133 | # Grouping the document tree into Texinfo files. List of tuples 134 | # (source start file, target name, title, author, 135 | # dir menu entry, description, category) 136 | texinfo_documents = [ 137 | (master_doc, 'JupyterHub', u'JupyterHub Documentation', 138 | author, 'JupyterHub', 'One line description of project.', 139 | 'Miscellaneous'), 140 | ] 141 | 142 | #texinfo_appendices = [] 143 | #texinfo_domain_indices = True 144 | #texinfo_show_urls = 'footnote' 145 | #texinfo_no_detailmenu = False 146 | 147 | 148 | # -- Epub output -------------------------------------------------------- 149 | 150 | # Bibliographic Dublin Core info. 151 | epub_title = project 152 | epub_author = author 153 | epub_publisher = author 154 | epub_copyright = copyright 155 | 156 | # A list of files that should not be packed into the epub file. 157 | epub_exclude_files = ['search.html'] 158 | 159 | # -- Intersphinx ---------------------------------------------------------- 160 | 161 | intersphinx_mapping = {'https://docs.python.org/': None} 162 | 163 | # -- Read The Docs -------------------------------------------------------- 164 | 165 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 166 | 167 | if not on_rtd: 168 | # only import and set the theme if we're building docs locally 169 | import sphinx_rtd_theme 170 | html_theme = 'sphinx_rtd_theme' 171 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 172 | else: 173 | # readthedocs.org uses their theme by default, so no need to specify it 174 | # build rest-api, since RTD doesn't run make 175 | from subprocess import check_call as sh 176 | sh(['make', 'rest-api'], cwd=docs) 177 | 178 | # -- Spell checking ------------------------------------------------------- 179 | 180 | try: 181 | import sphinxcontrib.spelling 182 | except ImportError: 183 | pass 184 | else: 185 | extensions.append("sphinxcontrib.spelling") 186 | 187 | spelling_word_list_filename='spelling_wordlist.txt' 188 | -------------------------------------------------------------------------------- /docs/source/contributor-list.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | Project Jupyter thanks the following people for their help and 4 | contribution on JupyterHub: 5 | 6 | - anderbubble 7 | - betatim 8 | - Carreau 9 | - ckald 10 | - cwaldbieser 11 | - danielballen 12 | - daradib 13 | - datapolitan 14 | - dblockow-d2dcrc 15 | - dietmarw 16 | - DominicFollettSmith 17 | - dsblank 18 | - ellisonbg 19 | - evanlinde 20 | - Fokko 21 | - iamed18 22 | - JamiesHQ 23 | - jdavidheiser 24 | - jhamrick 25 | - josephtate 26 | - kinuax 27 | - KrishnaPG 28 | - ksolan 29 | - mbmilligan 30 | - minrk 31 | - mistercrunch 32 | - Mistobaan 33 | - mwmarkland 34 | - nthiery 35 | - ObiWahn 36 | - ozancaglayan 37 | - parente 38 | - PeterDaveHello 39 | - peterruppel 40 | - rafael-ladislau 41 | - rgbkrk 42 | - robnagler 43 | - ryanlovett 44 | - Scrypy 45 | - shreddd 46 | - spoorthyv 47 | - ssanderson 48 | - takluyver 49 | - temogen 50 | - TimShawver 51 | - Todd-Z-Li 52 | - toobaz 53 | - tsaeger 54 | - vilhelmen 55 | - willingc 56 | - YannBrrd 57 | - yuvipanda 58 | - zoltan-fedor 59 | -------------------------------------------------------------------------------- /docs/source/howitworks.md: -------------------------------------------------------------------------------- 1 | # How JupyterHub works 2 | 3 | JupyterHub is a multi-user server that manages and proxies multiple instances of the single-user Jupyter notebook server. 4 | 5 | There are three basic processes involved: 6 | 7 | - multi-user Hub (Python/Tornado) 8 | - [configurable http proxy](https://github.com/jupyterhub/configurable-http-proxy) (node-http-proxy) 9 | - multiple single-user IPython notebook servers (Python/IPython/Tornado) 10 | 11 | The proxy is the only process that listens on a public interface. 12 | The Hub sits behind the proxy at `/hub`. 13 | Single-user servers sit behind the proxy at `/user/[username]`. 14 | 15 | 16 | ## Logging in 17 | 18 | When a new browser logs in to JupyterHub, the following events take place: 19 | 20 | - Login data is handed to the [Authenticator](#authentication) instance for validation 21 | - The Authenticator returns the username, if login information is valid 22 | - A single-user server instance is [Spawned](#spawning) for the logged-in user 23 | - When the server starts, the proxy is notified to forward `/user/[username]/*` to the single-user server 24 | - Two cookies are set, one for `/hub/` and another for `/user/[username]`, 25 | containing an encrypted token. 26 | - The browser is redirected to `/user/[username]`, which is handled by the single-user server 27 | 28 | Logging into a single-user server is authenticated via the Hub: 29 | 30 | - On request, the single-user server forwards the encrypted cookie to the Hub for verification 31 | - The Hub replies with the username if it is a valid cookie 32 | - If the user is the owner of the server, access is allowed 33 | - If it is the wrong user or an invalid cookie, the browser is redirected to `/hub/login` 34 | 35 | 36 | ## Customizing JupyterHub 37 | 38 | There are two basic extension points for JupyterHub: How users are authenticated, 39 | and how their server processes are started. 40 | Each is governed by a customizable class, 41 | and JupyterHub ships with just the most basic version of each. 42 | 43 | To enable custom authentication and/or spawning, 44 | subclass Authenticator or Spawner, 45 | and override the relevant methods. 46 | 47 | 48 | ### Authentication 49 | 50 | Authentication is customizable via the Authenticator class. 51 | Authentication can be replaced by any mechanism, 52 | such as OAuth, Kerberos, etc. 53 | 54 | JupyterHub only ships with [PAM](https://en.wikipedia.org/wiki/Pluggable_authentication_module) authentication, 55 | which requires the server to be run as root, 56 | or at least with access to the PAM service, 57 | which regular users typically do not have 58 | (on Ubuntu, this requires being added to the `shadow` group). 59 | 60 | [More info on custom Authenticators](authenticators.html). 61 | 62 | See a list of custom Authenticators [on the wiki](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators). 63 | 64 | 65 | ### Spawning 66 | 67 | Each single-user server is started by a Spawner. 68 | The Spawner represents an abstract interface to a process, 69 | and needs to be able to take three actions: 70 | 71 | 1. start the process 72 | 2. poll whether the process is still running 73 | 3. stop the process 74 | 75 | [More info on custom Spawners](spawners.html). 76 | 77 | See a list of custom Spawners [on the wiki](https://github.com/jupyterhub/jupyterhub/wiki/Spawners). 78 | -------------------------------------------------------------------------------- /docs/source/images/hub-pieces.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantopian/jupyterhub/a8bd7a697e326274187753fabbb8b30fa18bd79b/docs/source/images/hub-pieces.png -------------------------------------------------------------------------------- /docs/source/images/jhub-parts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantopian/jupyterhub/a8bd7a697e326274187753fabbb8b30fa18bd79b/docs/source/images/jhub-parts.png -------------------------------------------------------------------------------- /docs/source/images/spawn-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantopian/jupyterhub/a8bd7a697e326274187753fabbb8b30fa18bd79b/docs/source/images/spawn-form.png -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | JupyterHub 2 | ========== 3 | 4 | With JupyterHub you can create a **multi-user Hub** which spawns, manages, 5 | and proxies multiple instances of the single-user 6 | `Jupyter notebook `_ server. 7 | Due to its flexibility and customization options, JupyterHub can be used to 8 | serve notebooks to a class of students, a corporate data science group, or a 9 | scientific research group. 10 | 11 | 12 | .. image:: images/jhub-parts.png 13 | :alt: JupyterHub subsystems 14 | :width: 40% 15 | :align: right 16 | 17 | 18 | Three subsystems make up JupyterHub: 19 | 20 | * a multi-user **Hub** (tornado process) 21 | * a **configurable http proxy** (node-http-proxy) 22 | * multiple **single-user Jupyter notebook servers** (Python/IPython/tornado) 23 | 24 | JupyterHub's basic flow of operations includes: 25 | 26 | - The Hub spawns a proxy 27 | - The proxy forwards all requests to the Hub by default 28 | - The Hub handles user login and spawns single-user servers on demand 29 | - The Hub configures the proxy to forward URL prefixes to the single-user notebook servers 30 | 31 | For convenient administration of the Hub, its users, and :doc:`services` 32 | (added in version 7.0), JupyterHub also provides a 33 | `REST API `__. 34 | 35 | Contents 36 | -------- 37 | 38 | **User Guide** 39 | 40 | * :doc:`quickstart` 41 | * :doc:`getting-started` 42 | * :doc:`howitworks` 43 | * :doc:`websecurity` 44 | * :doc:`rest` 45 | 46 | .. toctree:: 47 | :maxdepth: 2 48 | :hidden: 49 | :caption: User Guide 50 | 51 | quickstart 52 | getting-started 53 | howitworks 54 | websecurity 55 | rest 56 | 57 | **Configuration Guide** 58 | 59 | * :doc:`authenticators` 60 | * :doc:`spawners` 61 | * :doc:`services` 62 | * :doc:`config-examples` 63 | * :doc:`upgrading` 64 | * :doc:`troubleshooting` 65 | 66 | .. toctree:: 67 | :maxdepth: 2 68 | :hidden: 69 | :caption: Configuration Guide 70 | 71 | authenticators 72 | spawners 73 | services 74 | config-examples 75 | upgrading 76 | troubleshooting 77 | 78 | 79 | **API Reference** 80 | 81 | * :doc:`api/index` 82 | 83 | .. toctree:: 84 | :maxdepth: 2 85 | :hidden: 86 | :caption: API Reference 87 | 88 | api/index 89 | 90 | 91 | **About JupyterHub** 92 | 93 | * :doc:`changelog` 94 | * :doc:`contributor-list` 95 | 96 | .. toctree:: 97 | :maxdepth: 2 98 | :hidden: 99 | :caption: About JupyterHub 100 | 101 | changelog 102 | contributor-list 103 | 104 | 105 | Indices and tables 106 | ------------------ 107 | 108 | * :ref:`genindex` 109 | * :ref:`modindex` 110 | 111 | 112 | Questions? Suggestions? 113 | ----------------------- 114 | 115 | - `Jupyter mailing list `_ 116 | - `Jupyter website `_ 117 | -------------------------------------------------------------------------------- /docs/source/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quickstart - Installation 2 | 3 | ## Prerequisites 4 | 5 | **Before installing JupyterHub**, you will need: 6 | 7 | - [Python](https://www.python.org/downloads/) 3.3 or greater 8 | 9 | An understanding of using [`pip`](https://pip.pypa.io/en/stable/) or 10 | [`conda`](http://conda.pydata.org/docs/get-started.html) for 11 | installing Python packages is helpful. 12 | 13 | - [nodejs/npm](https://www.npmjs.com/) 14 | 15 | [Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node), 16 | using your operating system's package manager. For example, install on Linux 17 | (Debian/Ubuntu) using: 18 | 19 | ```bash 20 | sudo apt-get install npm nodejs-legacy 21 | ``` 22 | 23 | (The `nodejs-legacy` package installs the `node` executable and is currently 24 | required for npm to work on Debian/Ubuntu.) 25 | 26 | - TLS certificate and key for HTTPS communication 27 | 28 | - Domain name 29 | 30 | **Before running the single-user notebook servers** (which may be on the same 31 | system as the Hub or not): 32 | 33 | - [Jupyter Notebook](https://jupyter.readthedocs.io/en/latest/install.html) 34 | version 4 or greater 35 | 36 | ## Installation 37 | 38 | JupyterHub can be installed with `pip` or `conda` and the proxy with `npm`: 39 | 40 | **pip, npm:** 41 | ```bash 42 | python3 -m pip install jupyterhub 43 | npm install -g configurable-http-proxy 44 | ``` 45 | 46 | **conda** (one command installs jupyterhub and proxy): 47 | ```bash 48 | conda install -c conda-forge jupyterhub 49 | ``` 50 | 51 | To test your installation: 52 | 53 | ```bash 54 | jupyterhub -h 55 | configurable-http-proxy -h 56 | ``` 57 | 58 | If you plan to run notebook servers locally, you will need also to install 59 | Jupyter notebook: 60 | 61 | **pip:** 62 | ```bash 63 | python3 -m pip install notebook 64 | ``` 65 | 66 | **conda:** 67 | ```bash 68 | conda install notebook 69 | ``` 70 | 71 | ## Start the Hub server 72 | 73 | To start the Hub server, run the command: 74 | 75 | ```bash 76 | jupyterhub 77 | ``` 78 | 79 | Visit `https://localhost:8000` in your browser, and sign in with your unix 80 | credentials. 81 | 82 | To allow multiple users to sign into the Hub server, you must start `jupyterhub` as a *privileged user*, such as root: 83 | 84 | ```bash 85 | sudo jupyterhub 86 | ``` 87 | 88 | The [wiki](https://github.com/jupyterhub/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges) 89 | describes how to run the server as a *less privileged user*, which requires 90 | additional configuration of the system. 91 | 92 | ---- 93 | 94 | ## Basic Configuration 95 | 96 | The [getting started document](docs/source/getting-started.md) contains 97 | detailed information abouts configuring a JupyterHub deployment. 98 | 99 | The JupyterHub **tutorial** provides a video and documentation that explains 100 | and illustrates the fundamental steps for installation and configuration. 101 | [Repo](https://github.com/jupyterhub/jupyterhub-tutorial) 102 | | [Tutorial documentation](http://jupyterhub-tutorial.readthedocs.io/en/latest/) 103 | 104 | #### Generate a default configuration file 105 | 106 | Generate a default config file: 107 | 108 | jupyterhub --generate-config 109 | 110 | #### Customize the configuration, authentication, and process spawning 111 | 112 | Spawn the server on ``10.0.1.2:443`` with **https**: 113 | 114 | jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert 115 | 116 | The authentication and process spawning mechanisms can be replaced, 117 | which should allow plugging into a variety of authentication or process 118 | control environments. Some examples, meant as illustration and testing of this 119 | concept, are: 120 | 121 | - Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyterhub/oauthenticator) 122 | - Spawning single-user servers with Docker, using the [DockerSpawner](https://github.com/jupyterhub/dockerspawner) 123 | 124 | ---- 125 | 126 | ## Alternate Installation using Docker 127 | 128 | A ready to go [docker image for JupyterHub](https://hub.docker.com/r/jupyterhub/jupyterhub/) 129 | gives a straightforward deployment of JupyterHub. 130 | 131 | *Note: This `jupyterhub/jupyterhub` docker image is only an image for running 132 | the Hub service itself. It does not provide the other Jupyter components, such 133 | as Notebook installation, which are needed by the single-user servers. 134 | To run the single-user servers, which may be on the same system as the Hub or 135 | not, Jupyter Notebook version 4 or greater must be installed.* 136 | 137 | #### Starting JupyterHub with docker 138 | 139 | The JupyterHub docker image can be started with the following command: 140 | 141 | docker run -d --name jupyterhub jupyterhub/jupyterhub jupyterhub 142 | 143 | This command will create a container named `jupyterhub` that you can 144 | **stop and resume** with `docker stop/start`. 145 | 146 | The Hub service will be listening on all interfaces at port 8000, which makes 147 | this a good choice for **testing JupyterHub on your desktop or laptop**. 148 | 149 | If you want to run docker on a computer that has a public IP then you should 150 | (as in MUST) **secure it with ssl** by adding ssl options to your docker 151 | configuration or using a ssl enabled proxy. 152 | 153 | [Mounting volumes](https://docs.docker.com/engine/userguide/containers/dockervolumes/) 154 | will allow you to **store data outside the docker image (host system) so it will be persistent**, 155 | even when you start a new image. 156 | 157 | The command `docker exec -it jupyterhub bash` will spawn a root shell in your 158 | docker container. You can **use the root shell to create system users in the container**. 159 | These accounts will be used for authentication in JupyterHub's default 160 | configuration. 161 | -------------------------------------------------------------------------------- /docs/source/rest.md: -------------------------------------------------------------------------------- 1 | # Using JupyterHub's REST API 2 | 3 | Using the [JupyterHub REST API][], you can perform actions on the Hub, 4 | such as: 5 | 6 | - checking which users are active 7 | - adding or removing users 8 | - stopping or starting single user notebook servers 9 | - authenticating services 10 | 11 | A [REST](https://en.wikipedia.org/wiki/Representational_state_transfer) 12 | API provides a standard way for users to get and send information to the 13 | Hub. 14 | 15 | 16 | ## Creating an API token 17 | To send requests using JupyterHub API, you must pass an API token with the 18 | request. You can create a token for an individual user using the following 19 | command: 20 | 21 | jupyterhub token USERNAME 22 | 23 | 24 | ## Adding tokens to the config file 25 | You may also add a dictionary of API tokens and usernames to the hub's 26 | configuration file, `jupyterhub_config.py`: 27 | 28 | ```python 29 | c.JupyterHub.api_tokens = { 30 | 'secret-token': 'username', 31 | } 32 | ``` 33 | 34 | 35 | ## Making an API request 36 | 37 | To authenticate your requests, pass the API token in the request's 38 | Authorization header. 39 | 40 | **Example: List the hub's users** 41 | 42 | Using the popular Python requests library, the following code sends an API 43 | request and an API token for authorization: 44 | 45 | ```python 46 | import requests 47 | 48 | api_url = 'http://127.0.0.1:8081/hub/api' 49 | 50 | r = requests.get(api_url + '/users', 51 | headers={ 52 | 'Authorization': 'token %s' % token, 53 | } 54 | ) 55 | 56 | r.raise_for_status() 57 | users = r.json() 58 | ``` 59 | 60 | 61 | ## Learning more about the API 62 | 63 | You can see the full [JupyterHub REST API][] for details. 64 | The same REST API Spec can be viewed in a more interactive style [on swagger's petstore][]. 65 | Both resources contain the same information and differ only in its display. 66 | Note: The Swagger specification is being renamed the [OpenAPI Initiative][]. 67 | 68 | [on swagger's petstore]: http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/docs/rest-api.yml#!/default 69 | [OpenAPI Initiative]: https://www.openapis.org/ 70 | [JupyterHub REST API]: ./_static/rest-api/index.html 71 | -------------------------------------------------------------------------------- /docs/source/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | admin 2 | Afterwards 3 | alchemyst 4 | alope 5 | api 6 | API 7 | apps 8 | args 9 | asctime 10 | auth 11 | authenticator 12 | Authenticator 13 | authenticators 14 | Authenticators 15 | Autograde 16 | autograde 17 | autogradeapp 18 | autograded 19 | Autograded 20 | autograder 21 | Autograder 22 | autograding 23 | backends 24 | Bitdiddle 25 | bugfix 26 | Bugfixes 27 | bugtracker 28 | Carreau 29 | Changelog 30 | changelog 31 | checksum 32 | checksums 33 | cmd 34 | cogsci 35 | conda 36 | config 37 | coroutine 38 | coroutines 39 | crt 40 | customizable 41 | datefmt 42 | decrypted 43 | dev 44 | DockerSpawner 45 | dockerspawner 46 | dropdown 47 | duedate 48 | Duedate 49 | ellachao 50 | ellisonbg 51 | entrypoint 52 | env 53 | Filenames 54 | filesystem 55 | formatters 56 | formdata 57 | formgrade 58 | formgrader 59 | gif 60 | GitHub 61 | Gradebook 62 | gradebook 63 | Granger 64 | hardcoded 65 | hOlle 66 | Homebrew 67 | html 68 | http 69 | https 70 | hubapi 71 | Indices 72 | IFramed 73 | inline 74 | iopub 75 | ip 76 | ipynb 77 | IPython 78 | ischurov 79 | ivanslapnicar 80 | jdfreder 81 | jhamrick 82 | jklymak 83 | jonathanmorgan 84 | joschu 85 | JUPYTER 86 | Jupyter 87 | jupyter 88 | jupyterhub 89 | Kerberos 90 | kerberos 91 | letsencrypt 92 | lgpage 93 | linkcheck 94 | linux 95 | localhost 96 | logfile 97 | login 98 | logins 99 | logout 100 | lookup 101 | lphk 102 | mandli 103 | Marr 104 | mathjax 105 | matplotlib 106 | metadata 107 | mikebolt 108 | minrk 109 | Mitigations 110 | mixin 111 | Mixin 112 | multi 113 | multiuser 114 | namespace 115 | nbconvert 116 | nbgrader 117 | neuroscience 118 | nginx 119 | np 120 | npm 121 | oauth 122 | OAuth 123 | oauthenticator 124 | ok 125 | olgabot 126 | osx 127 | PAM 128 | phantomjs 129 | Phantomjs 130 | plugin 131 | plugins 132 | Popen 133 | positionally 134 | postgres 135 | pregenerated 136 | prepend 137 | prepopulate 138 | preprocessor 139 | Preprocessor 140 | prev 141 | Programmatically 142 | programmatically 143 | ps 144 | py 145 | Qualys 146 | quickstart 147 | readonly 148 | redSlug 149 | reinstall 150 | resize 151 | rst 152 | runtime 153 | rw 154 | sandboxed 155 | sansary 156 | singleuser 157 | smeylan 158 | spawner 159 | Spawner 160 | spawners 161 | Spawners 162 | spellcheck 163 | SQL 164 | sqlite 165 | startup 166 | statsd 167 | stdin 168 | stdout 169 | stoppped 170 | subclasses 171 | subcommand 172 | subdomain 173 | subdomains 174 | Subdomains 175 | suchow 176 | suprocesses 177 | svurens 178 | sys 179 | SystemUserSpawner 180 | systemwide 181 | tasilb 182 | teardown 183 | threadsafe 184 | timestamp 185 | timestamps 186 | TLD 187 | todo 188 | toolbar 189 | traitlets 190 | travis 191 | tuples 192 | undeletable 193 | unicode 194 | uninstall 195 | UNIX 196 | unix 197 | untracked 198 | untrusted 199 | url 200 | username 201 | usernames 202 | utcnow 203 | utils 204 | vinaykola 205 | virtualenv 206 | whitelist 207 | whitespace 208 | wildcard 209 | Wildcards 210 | willingc 211 | wordlist 212 | Workflow 213 | workflow -------------------------------------------------------------------------------- /docs/source/upgrading.md: -------------------------------------------------------------------------------- 1 | # Upgrading JupyterHub and its database 2 | 3 | From time to time, you may wish to upgrade JupyterHub to take advantage 4 | of new releases. Much of this process is automated using scripts, 5 | such as those generated by alembic for database upgrades. Before upgrading a 6 | JupyterHub deployment, it's critical to backup your data and configurations 7 | before shutting down the JupyterHub process and server. 8 | 9 | ## Databases: SQLite (default) or RDBMS (PostgreSQL, MySQL) 10 | 11 | The default database for JupyterHub is a [SQLite](https://sqlite.org) database. 12 | We have chosen SQLite as JupyterHub's default for its lightweight simplicity 13 | in certain uses such as testing, small deployments and workshops. 14 | 15 | When running a long term deployment or a production system, we recommend using 16 | a traditional RDBMS database, such as [PostgreSQL](https://www.postgresql.org) 17 | or [MySQL](https://www.mysql.com), that supports the SQL `ALTER TABLE` 18 | statement. 19 | 20 | For production systems, SQLite has some disadvantages when used with JupyterHub: 21 | 22 | - `upgrade-db` may not work, and you may need to start with a fresh database 23 | - `downgrade-db` **will not** work if you want to rollback to an earlier 24 | version, so backup the `jupyterhub.sqlite` file before upgrading 25 | 26 | The sqlite documentation provides a helpful page about [when to use sqlite and 27 | where traditional RDBMS may be a better choice](https://sqlite.org/whentouse.html). 28 | 29 | ## The upgrade process 30 | 31 | Four fundamental process steps are needed when upgrading JupyterHub and its 32 | database: 33 | 34 | 1. Backup JupyterHub database 35 | 2. Backup JupyterHub configuration file 36 | 3. Shutdown the Hub 37 | 4. Upgrade JupyterHub 38 | 5. Upgrade the database using run `jupyterhub upgrade-db` 39 | 40 | Let's take a closer look at each step in the upgrade process as well as some 41 | additional information about JupyterHub databases. 42 | 43 | ### Backup JupyterHub database 44 | 45 | To prevent unintended loss of data or configuration information, you should 46 | back up the JupyterHub database (the default SQLite database or a RDBMS 47 | database using PostgreSQL, MySQL, or others supported by SQLAlchemy): 48 | 49 | - If using the default SQLite database, back up the `jupyterhub.sqlite` 50 | database. 51 | - If using an RDBMS database such as PostgreSQL, MySQL, or other supported by 52 | SQLAlchemy, back up the JupyterHub database. 53 | 54 | Losing the Hub database is often not a big deal. Information that resides only 55 | in the Hub database includes: 56 | 57 | - active login tokens (user cookies, service tokens) 58 | - users added via GitHub UI, instead of config files 59 | - info about running servers 60 | 61 | If the following conditions are true, you should be fine clearing the Hub 62 | database and starting over: 63 | 64 | - users specified in config file 65 | - user servers are stopped during upgrade 66 | - don't mind causing users to login again after upgrade 67 | 68 | ### Backup JupyterHub configuration file 69 | 70 | Additionally, backing up your configuration file, `jupyterhub_config.py`, to 71 | a secure location. 72 | 73 | ### Shutdown JupyterHub 74 | 75 | Prior to shutting down JupyterHub, you should notify the Hub users of the 76 | scheduled downtime. This gives users the opportunity to finish any outstanding 77 | work in process. 78 | 79 | Next, shutdown the JupyterHub service. 80 | 81 | ### Upgrade JupyterHub 82 | 83 | Follow directions that correspond to your package manager, `pip` or `conda`, 84 | for the new JupyterHub release. These directions will guide you to the 85 | specific command. In general, `pip install -U jupyterhub` or 86 | `conda upgrade jupyterhub` 87 | 88 | ### Upgrade JupyterHub databases 89 | 90 | To run the upgrade process for JupyterHub databases, enter: 91 | 92 | ``` 93 | jupyterhub upgrade-db 94 | ``` 95 | 96 | ## Upgrade checklist 97 | 98 | 1. Backup JupyterHub database: 99 | - `jupyterhub.sqlite` when using the default sqlite database 100 | - Your JupyterHub database when using an RDBMS 101 | 2. Backup JupyterHub configuration file: `jupyterhub_config.py` 102 | 3. Shutdown the Hub 103 | 4. Upgrade JupyterHub 104 | - `pip install -U jupyterhub` when using `pip` 105 | - `conda upgrade jupyterhub` when using `conda` 106 | 5. Upgrade the database using run `jupyterhub upgrade-db` 107 | -------------------------------------------------------------------------------- /docs/source/websecurity.md: -------------------------------------------------------------------------------- 1 | # Web Security in JupyterHub 2 | 3 | JupyterHub is designed to be a simple multi-user server for modestly sized 4 | groups of semi-trusted users. While the design reflects serving semi-trusted 5 | users, JupyterHub is not necessarily unsuitable for serving untrusted users. 6 | Using JupyterHub with untrusted users does mean more work and much care is 7 | required to secure a Hub against untrusted users, with extra caution on 8 | protecting users from each other as the Hub is serving untrusted users. 9 | 10 | One aspect of JupyterHub's design simplicity for semi-trusted users is that 11 | the Hub and single-user servers are placed in a single domain, behind a 12 | [proxy][configurable-http-proxy]. As a result, if the Hub is serving untrusted 13 | users, many of the web's cross-site protections are not applied between 14 | single-user servers and the Hub, or between single-user servers and each 15 | other, since browsers see the whole thing (proxy, Hub, and single user 16 | servers) as a single website. 17 | 18 | To protect users from each other, a user must never be able to write arbitrary 19 | HTML and serve it to another user on the Hub's domain. JupyterHub's 20 | authentication setup prevents this because only the owner of a given 21 | single-user server is allowed to view user-authored pages served by their 22 | server. To protect all users from each other, JupyterHub administrators must 23 | ensure that: 24 | 25 | * A user does not have permission to modify their single-user server: 26 | - A user may not install new packages in the Python environment that runs 27 | their server. 28 | - If the PATH is used to resolve the single-user executable (instead of an 29 | absolute path), a user may not create new files in any PATH directory 30 | that precedes the directory containing jupyterhub-singleuser. 31 | - A user may not modify environment variables (e.g. PATH, PYTHONPATH) for 32 | their single-user server. 33 | * A user may not modify the configuration of the notebook server 34 | (the ~/.jupyter or JUPYTER_CONFIG_DIR directory). 35 | 36 | If any additional services are run on the same domain as the Hub, the services 37 | must never display user-authored HTML that is neither sanitized nor sandboxed 38 | (e.g. IFramed) to any user that lacks authentication as the author of a file. 39 | 40 | 41 | ## Mitigations 42 | 43 | There are two main configuration options provided by JupyterHub to mitigate 44 | these issues: 45 | 46 | ### Subdomains 47 | 48 | JupyterHub 0.5 adds the ability to run single-user servers on their own 49 | subdomains, which means the cross-origin protections between servers has the 50 | desired effect, and user servers and the Hub are protected from each other. A 51 | user's server will be at `username.jupyter.mydomain.com`, etc. This requires 52 | all user subdomains to point to the same address, which is most easily 53 | accomplished with wildcard DNS. Since this spreads the service across multiple 54 | domains, you will need wildcard SSL, as well. Unfortunately, for many 55 | institutional domains, wildcard DNS and SSL are not available, but if you do 56 | plan to serve untrusted users, enabling subdomains is highly encouraged, as it 57 | resolves all of the cross-site issues. 58 | 59 | ### Disabling user config 60 | 61 | If subdomains are not available or not desirable, 0.5 also adds an option 62 | `Spawner.disable_user_config`, which you can set to prevent the user-owned 63 | configuration files from being loaded. This leaves only package installation 64 | and PATHs as things the admin must enforce. 65 | 66 | For most Spawners, PATH is not something users can influence, but care should 67 | be taken to ensure that the Spawn does *not* evaluate shell configuration 68 | files prior to launching the server. 69 | 70 | Package isolation is most easily handled by running the single-user server in 71 | a virtualenv with disabled system-site-packages. 72 | 73 | ## Extra notes 74 | 75 | It is important to note that the control over the environment only affects the 76 | single-user server, and not the environment(s) in which the user's kernel(s) 77 | may run. Installing additional packages in the kernel environment does not 78 | pose additional risk to the web application's security. 79 | 80 | [configurable-http-proxy]: https://github.com/jupyterhub/configurable-http-proxy 81 | -------------------------------------------------------------------------------- /examples/cull-idle/README.md: -------------------------------------------------------------------------------- 1 | # `cull-idle` Example 2 | 3 | The `cull_idle_servers.py` file provides a script to cull and shut down idle 4 | single-user notebook servers. This script is used when `cull-idle` is run as 5 | a Service or when it is run manually as a standalone script. 6 | 7 | 8 | ## Configure `cull-idle` to run as a Hub-Managed Service 9 | 10 | In `jupyterhub_config.py`, add the following dictionary for the `cull-idle` 11 | Service to the `c.JupyterHub.services` list: 12 | 13 | ```python 14 | c.JupyterHub.services = [ 15 | { 16 | 'name': 'cull-idle', 17 | 'admin': True, 18 | 'command': 'python cull_idle_servers.py --timeout=3600'.split(), 19 | } 20 | ] 21 | ``` 22 | 23 | where: 24 | 25 | - `'admin': True` indicates that the Service has 'admin' permissions, and 26 | - `'command'` indicates that the Service will be managed by the Hub. 27 | 28 | ## Run `cull-idle` manually as a standalone script 29 | 30 | This will run `cull-idle` manually. `cull-idle` can be run as a standalone 31 | script anywhere with access to the Hub, and will periodically check for idle 32 | servers and shut them down via the Hub's REST API. In order to shutdown the 33 | servers, the token given to cull-idle must have admin privileges. 34 | 35 | Generate an API token and store it in the `JUPYTERHUB_API_TOKEN` environment 36 | variable. Run `cull_idle_servers.py` manually. 37 | 38 | ```bash 39 | export JUPYTERHUB_API_TOKEN=`jupyterhub token` 40 | python cull_idle_servers.py [--timeout=900] [--url=http://127.0.0.1:8081/hub/api] 41 | ``` 42 | -------------------------------------------------------------------------------- /examples/cull-idle/cull_idle_servers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """script to monitor and cull idle single-user servers 3 | 4 | Caveats: 5 | 6 | last_activity is not updated with high frequency, 7 | so cull timeout should be greater than the sum of: 8 | 9 | - single-user websocket ping interval (default: 30s) 10 | - JupyterHub.last_activity_interval (default: 5 minutes) 11 | 12 | You can run this as a service managed by JupyterHub with this in your config:: 13 | 14 | 15 | c.JupyterHub.services = [ 16 | { 17 | 'name': 'cull-idle', 18 | 'admin': True, 19 | 'command': 'python cull_idle_servers.py --timeout=3600'.split(), 20 | } 21 | ] 22 | 23 | Or run it manually by generating an API token and storing it in `JUPYTERHUB_API_TOKEN`: 24 | 25 | export JUPYTERHUB_API_TOKEN=`jupyterhub token` 26 | python cull_idle_servers.py [--timeout=900] [--url=http://127.0.0.1:8081/hub/api] 27 | """ 28 | 29 | import datetime 30 | import json 31 | import os 32 | 33 | from dateutil.parser import parse as parse_date 34 | 35 | from tornado.gen import coroutine 36 | from tornado.log import app_log 37 | from tornado.httpclient import AsyncHTTPClient, HTTPRequest 38 | from tornado.ioloop import IOLoop, PeriodicCallback 39 | from tornado.options import define, options, parse_command_line 40 | 41 | 42 | @coroutine 43 | def cull_idle(url, api_token, timeout): 44 | """cull idle single-user servers""" 45 | auth_header = { 46 | 'Authorization': 'token %s' % api_token 47 | } 48 | req = HTTPRequest(url=url + '/users', 49 | headers=auth_header, 50 | ) 51 | now = datetime.datetime.utcnow() 52 | cull_limit = now - datetime.timedelta(seconds=timeout) 53 | client = AsyncHTTPClient() 54 | resp = yield client.fetch(req) 55 | users = json.loads(resp.body.decode('utf8', 'replace')) 56 | futures = [] 57 | for user in users: 58 | last_activity = parse_date(user['last_activity']) 59 | if user['server'] and last_activity < cull_limit: 60 | app_log.info("Culling %s (inactive since %s)", user['name'], last_activity) 61 | req = HTTPRequest(url=url + '/users/%s/server' % user['name'], 62 | method='DELETE', 63 | headers=auth_header, 64 | ) 65 | futures.append((user['name'], client.fetch(req))) 66 | elif user['server'] and last_activity > cull_limit: 67 | app_log.debug("Not culling %s (active since %s)", user['name'], last_activity) 68 | 69 | for (name, f) in futures: 70 | yield f 71 | app_log.debug("Finished culling %s", name) 72 | 73 | if __name__ == '__main__': 74 | define('url', default=os.environ.get('JUPYTERHUB_API_URL'), help="The JupyterHub API URL") 75 | define('timeout', default=600, help="The idle timeout (in seconds)") 76 | define('cull_every', default=0, help="The interval (in seconds) for checking for idle servers to cull") 77 | 78 | parse_command_line() 79 | if not options.cull_every: 80 | options.cull_every = options.timeout // 2 81 | 82 | api_token = os.environ['JUPYTERHUB_API_TOKEN'] 83 | 84 | loop = IOLoop.current() 85 | cull = lambda : cull_idle(options.url, api_token, options.timeout) 86 | # run once before scheduling periodic call 87 | loop.run_sync(cull) 88 | # schedule periodic cull 89 | pc = PeriodicCallback(cull, 1e3 * options.cull_every) 90 | pc.start() 91 | try: 92 | loop.start() 93 | except KeyboardInterrupt: 94 | pass 95 | -------------------------------------------------------------------------------- /examples/cull-idle/jupyterhub_config.py: -------------------------------------------------------------------------------- 1 | # run cull-idle as a service 2 | c.JupyterHub.services = [ 3 | { 4 | 'name': 'cull-idle', 5 | 'admin': True, 6 | 'command': 'python cull_idle_servers.py --timeout=3600'.split(), 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /examples/postgres/README.md: -------------------------------------------------------------------------------- 1 | ## Postgres Dockerfile 2 | 3 | This example shows how you can connect Jupyterhub to a Postgres database 4 | instead of the default SQLite backend. 5 | 6 | ### Running Postgres with Jupyterhub on the host. 7 | 0. Uncomment and replace `ENV JPY_PSQL_PASSWORD arglebargle` with your own 8 | password in the Dockerfile for `examples/postgres/db`. (Alternatively, pass 9 | -e `JPY_PSQL_PASSWORD=` when you start the db container.) 10 | 11 | 1. `cd` to the root of your jupyterhub repo. 12 | 13 | 2. Build the postgres image with `docker build -t jupyterhub-postgres-db 14 | examples/postgres/db`. This may take a minute or two the first time it's 15 | run. 16 | 17 | 3. Run the db image with `docker run -d -p 5433:5432 jupyterhub-postgres-db`. 18 | This will start a postgres daemon container in the background that's 19 | listening on your localhost port 5433. 20 | 21 | 4. Run jupyterhub with 22 | `jupyterhub --db=postgresql://jupyterhub:@localhost:5433/jupyterhub`. 23 | 24 | 5. Log in as the user running jupyterhub on your host machine. 25 | 26 | ### Running Postgres with Containerized Jupyterhub. 27 | 0. Do steps 0-2 in from the above section, ensuring that the values set/passed 28 | for `JPY_PSQL_PASSWORD` match for the hub and db containers. 29 | 30 | 1. Build the hub image with `docker build -t jupyterhub-postgres-hub 31 | examples/postgres/hub`. This may take a minute or two the first time it's 32 | run. 33 | 34 | 2. Run the db image with `docker run -d --name=jpy-db 35 | jupyterhub-postgres`. Note that, unlike when connecting to a host machine 36 | jupyterhub, we don't specify a port-forwarding scheme here, but we do need 37 | to specify a name for the container. 38 | 39 | 3. Run the containerized hub with `docker run -it --link jpy-db:postgres 40 | jupyterhub-postgres-hub`. This instructs docker to run the hub container 41 | with a link to the already-running db container, which will forward 42 | environment and connection information from the DB to the hub. 43 | 44 | 4. Log in as one of the users defined in the `examples/postgres/hub/` 45 | Dockerfile. By default `rhea` is the server's admin user, `io` and 46 | `ganymede` are non-admin users, and all users' passwords are their 47 | usernames. 48 | -------------------------------------------------------------------------------- /examples/postgres/db/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:9.3 2 | 3 | RUN mkdir /docker-entrypoint-initdb.d 4 | 5 | # initdb.sh will be run by the parent container's entrypoint on container 6 | # startup, prior to the the database being started. 7 | COPY initdb.sh /docker-entrypoint-initdb.d/init.sh 8 | 9 | # Uncomment and replace this with your own password. 10 | # ENV JPY_PSQL_PASSWORD arglebargle 11 | -------------------------------------------------------------------------------- /examples/postgres/db/initdb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo ""; 4 | echo "Running initdb.sh."; 5 | if [ -z "$JPY_PSQL_PASSWORD" ]; then 6 | echo "Need to set JPY_PSQL_PASSWORD in Dockerfile or via command line."; 7 | exit 1; 8 | elif [ "$JPY_PSQL_PASSWORD" == "arglebargle" ]; then 9 | echo "WARNING: Running with default password!" 10 | echo "You are STRONGLY ADVISED to use your own password."; 11 | fi 12 | echo ""; 13 | 14 | # Start a postgres daemon, ignoring log output. 15 | gosu postgres pg_ctl start -w -l /dev/null 16 | 17 | # Create a Jupyterhub user and database. 18 | gosu postgres psql -c "CREATE DATABASE jupyterhub;" 19 | gosu postgres psql -c "CREATE USER jupyterhub WITH ENCRYPTED PASSWORD '$JPY_PSQL_PASSWORD';" 20 | gosu postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE jupyterhub TO jupyterhub;" 21 | 22 | # Alter pg_hba.conf to actually require passwords. The default image exposes 23 | # allows any user to connect without requiring a password, which is a liability 24 | # if this is run forwarding ports from the host machine. 25 | sed -ri -e 's/(host all all 0.0.0.0\/0 )(trust)/\1md5/' "$PGDATA"/pg_hba.conf 26 | 27 | # Stop the daemon. The root Dockerfile will restart the server for us. 28 | gosu postgres pg_ctl stop -w 29 | -------------------------------------------------------------------------------- /examples/postgres/hub/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jupyter/jupyterhub-onbuild 2 | 3 | MAINTAINER Jupyter Project 4 | 5 | RUN apt-get install -y libpq-dev \ 6 | && apt-get autoremove -y \ 7 | && apt-get clean -y \ 8 | && pip3 install psycopg2 9 | 10 | RUN useradd -m -G shadow -p $(openssl passwd -1 rhea) rhea 11 | RUN chown rhea . 12 | 13 | RUN for name in io ganymede ; do useradd -m -p $(openssl passwd -1 $name) $name; done 14 | 15 | USER rhea 16 | -------------------------------------------------------------------------------- /examples/postgres/hub/jupyterhub_config.py: -------------------------------------------------------------------------------- 1 | # Configuration file for jupyterhub (postgres example). 2 | 3 | c = get_config() 4 | 5 | # Add some users. 6 | c.JupyterHub.admin_users = {'rhea'} 7 | c.Authenticator.whitelist = {'ganymede', 'io', 'rhea'} 8 | 9 | # These environment variables are automatically supplied by the linked postgres 10 | # container. 11 | import os; 12 | pg_pass = os.getenv('POSTGRES_ENV_JPY_PSQL_PASSWORD') 13 | pg_host = os.getenv('POSTGRES_PORT_5432_TCP_ADDR') 14 | c.JupyterHub.db_url = 'postgresql://jupyterhub:{}@{}:5432/jupyterhub'.format( 15 | pg_pass, 16 | pg_host, 17 | ) 18 | -------------------------------------------------------------------------------- /examples/service-whoami-flask/README.md: -------------------------------------------------------------------------------- 1 | # Authenticating a flask service with JupyterHub 2 | 3 | Uses `jupyterhub.services.HubAuth` to authenticate requests with the Hub in a [flask][] application. 4 | 5 | ## Run 6 | 7 | 1. Launch JupyterHub and the `whoami service` with 8 | 9 | jupyterhub --ip=127.0.0.1 10 | 11 | 2. Visit http://127.0.0.1:8000/services/whoami 12 | 13 | After logging in with your local-system credentials, you should see a JSON dump of your user info: 14 | 15 | ```json 16 | { 17 | "admin": false, 18 | "last_activity": "2016-05-27T14:05:18.016372", 19 | "name": "queequeg", 20 | "pending": null, 21 | "server": "/user/queequeg" 22 | } 23 | ``` 24 | 25 | This relies on the Hub starting the whoami service, via config (see [jupyterhub_config.py](./jupyterhub_config.py)). 26 | 27 | A similar service could be run externally, by setting the JupyterHub service environment variables: 28 | 29 | JUPYTERHUB_API_TOKEN 30 | JUPYTERHUB_SERVICE_PREFIX 31 | 32 | 33 | [flask]: http://flask.pocoo.org 34 | -------------------------------------------------------------------------------- /examples/service-whoami-flask/jupyterhub_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | c.JupyterHub.services = [ 5 | { 6 | 'name': 'whoami', 7 | 'url': 'http://127.0.0.1:10101', 8 | 'command': ['flask', 'run', '--port=10101'], 9 | 'environment': { 10 | 'FLASK_APP': 'whoami-flask.py', 11 | } 12 | } 13 | ] 14 | -------------------------------------------------------------------------------- /examples/service-whoami-flask/launch.sh: -------------------------------------------------------------------------------- 1 | export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32` 2 | 3 | # start JupyterHub 4 | jupyterhub --ip=127.0.0.1 5 | -------------------------------------------------------------------------------- /examples/service-whoami-flask/whoami-flask.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | whoami service authentication with the Hub 4 | """ 5 | 6 | from functools import wraps 7 | import json 8 | import os 9 | from urllib.parse import quote 10 | 11 | from flask import Flask, redirect, request, Response 12 | 13 | from jupyterhub.services.auth import HubAuth 14 | 15 | 16 | prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/') 17 | 18 | auth = HubAuth( 19 | api_token=os.environ['JUPYTERHUB_API_TOKEN'], 20 | cookie_cache_max_age=60, 21 | ) 22 | 23 | app = Flask(__name__) 24 | 25 | 26 | def authenticated(f): 27 | """Decorator for authenticating with the Hub""" 28 | @wraps(f) 29 | def decorated(*args, **kwargs): 30 | cookie = request.cookies.get(auth.cookie_name) 31 | if cookie: 32 | user = auth.user_for_cookie(cookie) 33 | else: 34 | user = None 35 | if user: 36 | return f(user, *args, **kwargs) 37 | else: 38 | # redirect to login url on failed auth 39 | return redirect(auth.login_url + '?next=%s' % quote(request.path)) 40 | return decorated 41 | 42 | 43 | @app.route(prefix + '/') 44 | @authenticated 45 | def whoami(user): 46 | return Response( 47 | json.dumps(user, indent=1, sort_keys=True), 48 | mimetype='application/json', 49 | ) 50 | 51 | -------------------------------------------------------------------------------- /examples/service-whoami-flask/whoami.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantopian/jupyterhub/a8bd7a697e326274187753fabbb8b30fa18bd79b/examples/service-whoami-flask/whoami.png -------------------------------------------------------------------------------- /examples/service-whoami-flask/whoami.py: -------------------------------------------------------------------------------- 1 | """An example service authenticating with the Hub. 2 | 3 | This example service serves `/services/whoami/`, 4 | authenticated with the Hub, 5 | showing the user their own info. 6 | """ 7 | from getpass import getuser 8 | import json 9 | import os 10 | from urllib.parse import urlparse 11 | 12 | from tornado.ioloop import IOLoop 13 | from tornado.httpserver import HTTPServer 14 | from tornado.web import RequestHandler, Application, authenticated 15 | 16 | from jupyterhub.services.auth import HubAuthenticated 17 | 18 | 19 | class WhoAmIHandler(HubAuthenticated, RequestHandler): 20 | hub_users = {getuser()} # the users allowed to access this service 21 | 22 | @authenticated 23 | def get(self): 24 | user_model = self.get_current_user() 25 | self.set_header('content-type', 'application/json') 26 | self.write(json.dumps(user_model, indent=1, sort_keys=True)) 27 | 28 | def main(): 29 | app = Application([ 30 | (os.environ['JUPYTERHUB_SERVICE_PREFIX'] + '/?', WhoAmIHandler), 31 | (r'.*', WhoAmIHandler), 32 | ], login_url='/hub/login') 33 | 34 | http_server = HTTPServer(app) 35 | url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL']) 36 | 37 | http_server.listen(url.port, url.hostname) 38 | 39 | IOLoop.current().start() 40 | 41 | if __name__ == '__main__': 42 | main() 43 | -------------------------------------------------------------------------------- /examples/service-whoami/README.md: -------------------------------------------------------------------------------- 1 | # Authenticating a service with JupyterHub 2 | 3 | Uses `jupyterhub.services.HubAuthenticated` to authenticate requests with the Hub. 4 | 5 | ## Run 6 | 7 | 1. Launch JupyterHub and the `whoami service` with 8 | 9 | jupyterhub --ip=127.0.0.1 10 | 11 | 2. Visit http://127.0.0.1:8000/services/whoami 12 | 13 | After logging in with your local-system credentials, you should see a JSON dump of your user info: 14 | 15 | ```json 16 | { 17 | "admin": false, 18 | "last_activity": "2016-05-27T14:05:18.016372", 19 | "name": "queequeg", 20 | "pending": null, 21 | "server": "/user/queequeg" 22 | } 23 | ``` 24 | 25 | This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)). 26 | 27 | A similar service could be run externally, by setting the JupyterHub service environment variables: 28 | 29 | JUPYTERHUB_API_TOKEN 30 | JUPYTERHUB_SERVICE_PREFIX 31 | 32 | or instantiating and configuring a HubAuth object yourself, and attaching it as `self.hub_auth` in your HubAuthenticated handlers. 33 | -------------------------------------------------------------------------------- /examples/service-whoami/jupyterhub_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | c.JupyterHub.services = [ 5 | { 6 | 'name': 'whoami', 7 | 'url': 'http://127.0.0.1:10101', 8 | 'command': [sys.executable, './whoami.py'], 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /examples/service-whoami/whoami.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantopian/jupyterhub/a8bd7a697e326274187753fabbb8b30fa18bd79b/examples/service-whoami/whoami.png -------------------------------------------------------------------------------- /examples/service-whoami/whoami.py: -------------------------------------------------------------------------------- 1 | """An example service authenticating with the Hub. 2 | 3 | This serves `/services/whoami/`, authenticated with the Hub, showing the user their own info. 4 | """ 5 | from getpass import getuser 6 | import json 7 | import os 8 | from urllib.parse import urlparse 9 | 10 | from tornado.ioloop import IOLoop 11 | from tornado.httpserver import HTTPServer 12 | from tornado.web import RequestHandler, Application, authenticated 13 | 14 | from jupyterhub.services.auth import HubAuthenticated 15 | 16 | 17 | class WhoAmIHandler(HubAuthenticated, RequestHandler): 18 | hub_users = {getuser()} # the users allowed to access me 19 | 20 | @authenticated 21 | def get(self): 22 | user_model = self.get_current_user() 23 | self.set_header('content-type', 'application/json') 24 | self.write(json.dumps(user_model, indent=1, sort_keys=True)) 25 | 26 | def main(): 27 | app = Application([ 28 | (os.environ['JUPYTERHUB_SERVICE_PREFIX'] + '/?', WhoAmIHandler), 29 | (r'.*', WhoAmIHandler), 30 | ], login_url='/hub/login') 31 | 32 | http_server = HTTPServer(app) 33 | url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL']) 34 | 35 | http_server.listen(url.port, url.hostname) 36 | 37 | IOLoop.current().start() 38 | 39 | if __name__ == '__main__': 40 | main() -------------------------------------------------------------------------------- /examples/spawn-form/jupyterhub_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example JupyterHub config allowing users to specify environment variables and notebook-server args 3 | """ 4 | import shlex 5 | 6 | from jupyterhub.spawner import LocalProcessSpawner 7 | 8 | class DemoFormSpawner(LocalProcessSpawner): 9 | def _options_form_default(self): 10 | default_env = "YOURNAME=%s\n" % self.user.name 11 | return """ 12 | 13 | 14 | 15 | 16 | """.format(env=default_env) 17 | 18 | def options_from_form(self, formdata): 19 | options = {} 20 | options['env'] = env = {} 21 | 22 | env_lines = formdata.get('env', ['']) 23 | for line in env_lines[0].splitlines(): 24 | if line: 25 | key, value = line.split('=', 1) 26 | env[key.strip()] = value.strip() 27 | 28 | arg_s = formdata.get('args', [''])[0].strip() 29 | if arg_s: 30 | options['argv'] = shlex.split(arg_s) 31 | return options 32 | 33 | def get_args(self): 34 | """Return arguments to pass to the notebook server""" 35 | argv = super().get_args() 36 | if self.user_options.get('argv'): 37 | argv.extend(self.user_options['argv']) 38 | return argv 39 | 40 | def get_env(self): 41 | env = super().get_env() 42 | if self.user_options.get('env'): 43 | env.update(self.user_options['env']) 44 | return env 45 | 46 | c.JupyterHub.spawner_class = DemoFormSpawner 47 | -------------------------------------------------------------------------------- /jupyterhub/__init__.py: -------------------------------------------------------------------------------- 1 | from .version import version_info, __version__ 2 | 3 | -------------------------------------------------------------------------------- /jupyterhub/__main__.py: -------------------------------------------------------------------------------- 1 | from .app import main 2 | main() 3 | -------------------------------------------------------------------------------- /jupyterhub/_data.py: -------------------------------------------------------------------------------- 1 | """Get the data files for this package.""" 2 | 3 | def get_data_files(): 4 | """Walk up until we find share/jupyter/hub""" 5 | import sys 6 | from os.path import join, abspath, dirname, exists, split 7 | path = abspath(dirname(__file__)) 8 | starting_points = [path] 9 | if not path.startswith(sys.prefix): 10 | starting_points.append(sys.prefix) 11 | for path in starting_points: 12 | # walk up, looking for prefix/share/jupyter 13 | while path != '/': 14 | share_jupyter = join(path, 'share', 'jupyter', 'hub') 15 | if exists(join(share_jupyter, 'static', 'components')): 16 | return share_jupyter 17 | path, _ = split(path) 18 | # didn't find it, give up 19 | return '' 20 | 21 | 22 | # Package managers can just override this with the appropriate constant 23 | DATA_FILES_PATH = get_data_files() 24 | 25 | -------------------------------------------------------------------------------- /jupyterhub/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | script_location = {alembic_dir} 5 | sqlalchemy.url = {db_url} 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # max length of characters to apply to the 11 | # "slug" field 12 | #truncate_slug_length = 40 13 | 14 | # set to 'true' to run the environment during 15 | # the 'revision' command, regardless of autogenerate 16 | # revision_environment = false 17 | 18 | # set to 'true' to allow .pyc and .pyo files without 19 | # a source .py file to be detected as revisions in the 20 | # versions/ directory 21 | # sourceless = false 22 | 23 | # version location specification; this defaults 24 | # to jupyterhub/alembic/versions. When using multiple version 25 | # directories, initial revisions must be specified with --version-path 26 | # version_locations = %(here)s/bar %(here)s/bat jupyterhub/alembic/versions 27 | 28 | # the output encoding used when revision files 29 | # are written from script.py.mako 30 | # output_encoding = utf-8 31 | 32 | 33 | # Logging configuration 34 | [loggers] 35 | keys = root,sqlalchemy,alembic 36 | 37 | [handlers] 38 | keys = console 39 | 40 | [formatters] 41 | keys = generic 42 | 43 | [logger_root] 44 | level = WARN 45 | handlers = console 46 | qualname = 47 | 48 | [logger_sqlalchemy] 49 | level = WARN 50 | handlers = 51 | qualname = sqlalchemy.engine 52 | 53 | [logger_alembic] 54 | level = INFO 55 | handlers = 56 | qualname = alembic 57 | 58 | [handler_console] 59 | class = StreamHandler 60 | args = (sys.stderr,) 61 | level = NOTSET 62 | formatter = generic 63 | 64 | [formatter_generic] 65 | format = %(levelname)-5.5s [%(name)s] %(message)s 66 | datefmt = %H:%M:%S 67 | -------------------------------------------------------------------------------- /jupyterhub/alembic/README: -------------------------------------------------------------------------------- 1 | This is the alembic configuration for JupyterHub data base migrations. -------------------------------------------------------------------------------- /jupyterhub/alembic/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | 6 | # this is the Alembic Config object, which provides 7 | # access to the values within the .ini file in use. 8 | config = context.config 9 | 10 | # Interpret the config file for Python logging. 11 | # This line sets up loggers basically. 12 | fileConfig(config.config_file_name) 13 | 14 | # add your model's MetaData object here 15 | # for 'autogenerate' support 16 | # from myapp import mymodel 17 | # target_metadata = mymodel.Base.metadata 18 | target_metadata = None 19 | 20 | # other values from the config, defined by the needs of env.py, 21 | # can be acquired: 22 | # my_important_option = config.get_main_option("my_important_option") 23 | # ... etc. 24 | 25 | 26 | def run_migrations_offline(): 27 | """Run migrations in 'offline' mode. 28 | 29 | This configures the context with just a URL 30 | and not an Engine, though an Engine is acceptable 31 | here as well. By skipping the Engine creation 32 | we don't even need a DBAPI to be available. 33 | 34 | Calls to context.execute() here emit the given string to the 35 | script output. 36 | 37 | """ 38 | url = config.get_main_option("sqlalchemy.url") 39 | context.configure( 40 | url=url, target_metadata=target_metadata, literal_binds=True) 41 | 42 | with context.begin_transaction(): 43 | context.run_migrations() 44 | 45 | 46 | def run_migrations_online(): 47 | """Run migrations in 'online' mode. 48 | 49 | In this scenario we need to create an Engine 50 | and associate a connection with the context. 51 | 52 | """ 53 | connectable = engine_from_config( 54 | config.get_section(config.config_ini_section), 55 | prefix='sqlalchemy.', 56 | poolclass=pool.NullPool) 57 | 58 | with connectable.connect() as connection: 59 | context.configure( 60 | connection=connection, 61 | target_metadata=target_metadata 62 | ) 63 | 64 | with context.begin_transaction(): 65 | context.run_migrations() 66 | 67 | if context.is_offline_mode(): 68 | run_migrations_offline() 69 | else: 70 | run_migrations_online() 71 | -------------------------------------------------------------------------------- /jupyterhub/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | branch_labels = ${repr(branch_labels)} 13 | depends_on = ${repr(depends_on)} 14 | 15 | from alembic import op 16 | import sqlalchemy as sa 17 | ${imports if imports else ""} 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /jupyterhub/alembic/versions/19c0846f6344_base_revision_for_0_5.py: -------------------------------------------------------------------------------- 1 | """base revision for 0.5 2 | 3 | Revision ID: 19c0846f6344 4 | Revises: 5 | Create Date: 2016-04-11 16:05:34.873288 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '19c0846f6344' 11 | down_revision = None 12 | branch_labels = None 13 | depends_on = None 14 | 15 | from alembic import op 16 | import sqlalchemy as sa 17 | 18 | 19 | def upgrade(): 20 | pass 21 | 22 | 23 | def downgrade(): 24 | pass 25 | -------------------------------------------------------------------------------- /jupyterhub/alembic/versions/af4cbdb2d13c_services.py: -------------------------------------------------------------------------------- 1 | """services 2 | 3 | Revision ID: af4cbdb2d13c 4 | Revises: eeb276e51423 5 | Create Date: 2016-07-28 16:16:38.245348 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'af4cbdb2d13c' 11 | down_revision = 'eeb276e51423' 12 | branch_labels = None 13 | depends_on = None 14 | 15 | from alembic import op 16 | import sqlalchemy as sa 17 | 18 | 19 | def upgrade(): 20 | op.add_column('api_tokens', sa.Column('service_id', sa.Integer)) 21 | 22 | 23 | def downgrade(): 24 | # sqlite cannot downgrade because of limited ALTER TABLE support (no DROP COLUMN) 25 | op.drop_column('api_tokens', 'service_id') 26 | -------------------------------------------------------------------------------- /jupyterhub/alembic/versions/eeb276e51423_auth_state.py: -------------------------------------------------------------------------------- 1 | """auth_state 2 | 3 | Adds auth_state column to Users table. 4 | 5 | Revision ID: eeb276e51423 6 | Revises: 19c0846f6344 7 | Create Date: 2016-04-11 16:06:49.239831 8 | """ 9 | 10 | # revision identifiers, used by Alembic. 11 | revision = 'eeb276e51423' 12 | down_revision = '19c0846f6344' 13 | branch_labels = None 14 | depends_on = None 15 | 16 | from alembic import op 17 | import sqlalchemy as sa 18 | from jupyterhub.orm import JSONDict 19 | 20 | def upgrade(): 21 | op.add_column('users', sa.Column('auth_state', JSONDict)) 22 | 23 | 24 | def downgrade(): 25 | # sqlite cannot downgrade because of limited ALTER TABLE support (no DROP COLUMN) 26 | op.drop_column('users', 'auth_state') 27 | -------------------------------------------------------------------------------- /jupyterhub/apihandlers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | from . import auth, hub, proxy, users, groups, services 3 | 4 | default_handlers = [] 5 | for mod in (auth, hub, proxy, users, groups, services): 6 | default_handlers.extend(mod.default_handlers) 7 | -------------------------------------------------------------------------------- /jupyterhub/apihandlers/auth.py: -------------------------------------------------------------------------------- 1 | """Authorization handlers""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | 6 | import json 7 | from urllib.parse import quote 8 | 9 | from tornado import web, gen 10 | from .. import orm 11 | from ..utils import token_authenticated 12 | from .base import APIHandler 13 | 14 | 15 | class TokenAPIHandler(APIHandler): 16 | @token_authenticated 17 | def get(self, token): 18 | orm_token = orm.APIToken.find(self.db, token) 19 | if orm_token is None: 20 | raise web.HTTPError(404) 21 | self.write(json.dumps(self.user_model(self.users[orm_token.user]))) 22 | 23 | @gen.coroutine 24 | def post(self): 25 | if self.authenticator is not None: 26 | data = self.get_json_body() 27 | authenticated = yield self.authenticator.authenticate(self, data) 28 | if authenticated is None: 29 | raise web.HTTPError(403) 30 | 31 | if isinstance(authenticated, dict): 32 | authenticated = authenticated.get('name') 33 | user = self.find_user(authenticated) 34 | api_token = user.new_api_token() 35 | self.write(json.dumps({"Authentication":api_token})) 36 | else: 37 | raise web.HTTPError(404) 38 | 39 | class CookieAPIHandler(APIHandler): 40 | @token_authenticated 41 | def get(self, cookie_name, cookie_value=None): 42 | cookie_name = quote(cookie_name, safe='') 43 | if cookie_value is None: 44 | self.log.warning("Cookie values in request body is deprecated, use `/cookie_name/cookie_value`") 45 | cookie_value = self.request.body 46 | else: 47 | cookie_value = cookie_value.encode('utf8') 48 | user = self._user_for_cookie(cookie_name, cookie_value) 49 | if user is None: 50 | raise web.HTTPError(404) 51 | self.write(json.dumps(self.user_model(user))) 52 | 53 | 54 | default_handlers = [ 55 | (r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler), 56 | (r"/api/authorizations/token/([^/]+)", TokenAPIHandler), 57 | (r"/api/authorizations/token", TokenAPIHandler), 58 | ] 59 | -------------------------------------------------------------------------------- /jupyterhub/apihandlers/base.py: -------------------------------------------------------------------------------- 1 | """Base API handlers""" 2 | # Copyright (c) Jupyter Development Team. 3 | # Distributed under the terms of the Modified BSD License. 4 | 5 | import json 6 | 7 | from http.client import responses 8 | 9 | from tornado import web 10 | 11 | from ..handlers import BaseHandler 12 | from ..utils import url_path_join 13 | 14 | class APIHandler(BaseHandler): 15 | 16 | def check_referer(self): 17 | """Check Origin for cross-site API requests. 18 | 19 | Copied from WebSocket with changes: 20 | 21 | - allow unspecified host/referer (e.g. scripts) 22 | """ 23 | host = self.request.headers.get("Host") 24 | referer = self.request.headers.get("Referer") 25 | 26 | # If no header is provided, assume it comes from a script/curl. 27 | # We are only concerned with cross-site browser stuff here. 28 | if not host: 29 | self.log.warning("Blocking API request with no host") 30 | return False 31 | if not referer: 32 | self.log.warning("Blocking API request with no referer") 33 | return False 34 | 35 | host_path = url_path_join(host, self.hub.server.base_url) 36 | referer_path = referer.split('://', 1)[-1] 37 | if not (referer_path + '/').startswith(host_path): 38 | self.log.warning("Blocking Cross Origin API request. Referer: %s, Host: %s", 39 | referer, host_path) 40 | return False 41 | return True 42 | 43 | def get_current_user_cookie(self): 44 | """Override get_user_cookie to check Referer header""" 45 | cookie_user = super().get_current_user_cookie() 46 | # check referer only if there is a cookie user, 47 | # avoiding misleading "Blocking Cross Origin" messages 48 | # when there's no cookie set anyway. 49 | if cookie_user and not self.check_referer(): 50 | return None 51 | return cookie_user 52 | 53 | def get_json_body(self): 54 | """Return the body of the request as JSON data.""" 55 | if not self.request.body: 56 | return None 57 | body = self.request.body.strip().decode('utf-8') 58 | try: 59 | model = json.loads(body) 60 | except Exception: 61 | self.log.debug("Bad JSON: %r", body) 62 | self.log.error("Couldn't parse JSON", exc_info=True) 63 | raise web.HTTPError(400, 'Invalid JSON in body of request') 64 | return model 65 | 66 | def write_error(self, status_code, **kwargs): 67 | """Write JSON errors instead of HTML""" 68 | exc_info = kwargs.get('exc_info') 69 | message = '' 70 | status_message = responses.get(status_code, 'Unknown Error') 71 | if exc_info: 72 | exception = exc_info[1] 73 | # get the custom message, if defined 74 | try: 75 | message = exception.log_message % exception.args 76 | except Exception: 77 | pass 78 | 79 | # construct the custom reason, if defined 80 | reason = getattr(exception, 'reason', '') 81 | if reason: 82 | status_message = reason 83 | self.set_header('Content-Type', 'application/json') 84 | self.write(json.dumps({ 85 | 'status': status_code, 86 | 'message': message or status_message, 87 | })) 88 | 89 | def user_model(self, user): 90 | """Get the JSON model for a User object""" 91 | model = { 92 | 'name': user.name, 93 | 'admin': user.admin, 94 | 'groups': [ g.name for g in user.groups ], 95 | 'server': user.url if user.running else None, 96 | 'pending': None, 97 | 'last_activity': user.last_activity.isoformat(), 98 | } 99 | if user.spawn_pending: 100 | model['pending'] = 'spawn' 101 | elif user.stop_pending: 102 | model['pending'] = 'stop' 103 | return model 104 | 105 | def group_model(self, group): 106 | """Get the JSON model for a Group object""" 107 | return { 108 | 'name': group.name, 109 | 'users': [ u.name for u in group.users ] 110 | } 111 | 112 | _user_model_types = { 113 | 'name': str, 114 | 'admin': bool, 115 | 'groups': list, 116 | } 117 | 118 | _group_model_types = { 119 | 'name': str, 120 | 'users': list, 121 | } 122 | 123 | def _check_model(self, model, model_types, name): 124 | """Check a model provided by a REST API request 125 | 126 | Args: 127 | model (dict): user-provided model 128 | model_types (dict): dict of key:type used to validate types and keys 129 | name (str): name of the model, used in error messages 130 | """ 131 | if not isinstance(model, dict): 132 | raise web.HTTPError(400, "Invalid JSON data: %r" % model) 133 | if not set(model).issubset(set(model_types)): 134 | raise web.HTTPError(400, "Invalid JSON keys: %r" % model) 135 | for key, value in model.items(): 136 | if not isinstance(value, model_types[key]): 137 | raise web.HTTPError(400, "%s.%s must be %s, not: %r" % ( 138 | name, key, model_types[key], type(value) 139 | )) 140 | 141 | def _check_user_model(self, model): 142 | """Check a request-provided user model from a REST API""" 143 | self._check_model(model, self._user_model_types, 'user') 144 | for username in model.get('users', []): 145 | if not isinstance(username, str): 146 | raise web.HTTPError(400, ("usernames must be str, not %r", type(username))) 147 | 148 | def _check_group_model(self, model): 149 | """Check a request-provided group model from a REST API""" 150 | self._check_model(model, self._group_model_types, 'group') 151 | for groupname in model.get('groups', []): 152 | if not isinstance(groupname, str): 153 | raise web.HTTPError(400, ("group names must be str, not %r", type(groupname))) 154 | 155 | def options(self, *args, **kwargs): 156 | self.set_header('Access-Control-Allow-Headers', 'accept, content-type') 157 | self.finish() 158 | -------------------------------------------------------------------------------- /jupyterhub/apihandlers/groups.py: -------------------------------------------------------------------------------- 1 | """Group handlers""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | 6 | import json 7 | 8 | from tornado import gen, web 9 | 10 | from .. import orm 11 | from ..utils import admin_only 12 | from .base import APIHandler 13 | 14 | 15 | class _GroupAPIHandler(APIHandler): 16 | def _usernames_to_users(self, usernames): 17 | """Turn a list of usernames into user objects""" 18 | users = [] 19 | for username in usernames: 20 | username = self.authenticator.normalize_username(username) 21 | user = self.find_user(username) 22 | if user is None: 23 | raise web.HTTPError(400, "No such user: %s" % username) 24 | users.append(user.orm_user) 25 | return users 26 | 27 | def find_group(self, name): 28 | """Find and return a group by name. 29 | 30 | Raise 404 if not found. 31 | """ 32 | group = orm.Group.find(self.db, name=name) 33 | if group is None: 34 | raise web.HTTPError(404, "No such group: %s", name) 35 | return group 36 | 37 | class GroupListAPIHandler(_GroupAPIHandler): 38 | @admin_only 39 | def get(self): 40 | """List groups""" 41 | data = [ self.group_model(g) for g in self.db.query(orm.Group) ] 42 | self.write(json.dumps(data)) 43 | 44 | 45 | class GroupAPIHandler(_GroupAPIHandler): 46 | """View and modify groups by name""" 47 | 48 | @admin_only 49 | def get(self, name): 50 | group = self.find_group(name) 51 | self.write(json.dumps(self.group_model(group))) 52 | 53 | @admin_only 54 | @gen.coroutine 55 | def post(self, name): 56 | """POST creates a group by name""" 57 | model = self.get_json_body() 58 | if model is None: 59 | model = {} 60 | else: 61 | self._check_group_model(model) 62 | 63 | existing = orm.Group.find(self.db, name=name) 64 | if existing is not None: 65 | raise web.HTTPError(400, "Group %s already exists" % name) 66 | 67 | usernames = model.get('users', []) 68 | # check that users exist 69 | users = self._usernames_to_users(usernames) 70 | 71 | # create the group 72 | self.log.info("Creating new group %s with %i users", 73 | name, len(users), 74 | ) 75 | self.log.debug("Users: %s", usernames) 76 | group = orm.Group(name=name, users=users) 77 | self.db.add(group) 78 | self.db.commit() 79 | self.write(json.dumps(self.group_model(group))) 80 | self.set_status(201) 81 | 82 | @admin_only 83 | def delete(self, name): 84 | """Delete a group by name""" 85 | group = self.find_group(name) 86 | self.log.info("Deleting group %s", name) 87 | self.db.delete(group) 88 | self.db.commit() 89 | self.set_status(204) 90 | 91 | 92 | class GroupUsersAPIHandler(_GroupAPIHandler): 93 | """Modify a group's user list""" 94 | @admin_only 95 | def post(self, name): 96 | """POST adds users to a group""" 97 | group = self.find_group(name) 98 | data = self.get_json_body() 99 | self._check_group_model(data) 100 | if 'users' not in data: 101 | raise web.HTTPError(400, "Must specify users to add") 102 | self.log.info("Adding %i users to group %s", len(data['users']), name) 103 | self.log.debug("Adding: %s", data['users']) 104 | for user in self._usernames_to_users(data['users']): 105 | if user not in group.users: 106 | group.users.append(user) 107 | else: 108 | self.log.warning("User %s already in group %s", user.name, name) 109 | self.db.commit() 110 | self.write(json.dumps(self.group_model(group))) 111 | 112 | @gen.coroutine 113 | @admin_only 114 | def delete(self, name): 115 | """DELETE removes users from a group""" 116 | group = self.find_group(name) 117 | data = self.get_json_body() 118 | self._check_group_model(data) 119 | if 'users' not in data: 120 | raise web.HTTPError(400, "Must specify users to delete") 121 | self.log.info("Removing %i users from group %s", len(data['users']), name) 122 | self.log.debug("Removing: %s", data['users']) 123 | for user in self._usernames_to_users(data['users']): 124 | if user in group.users: 125 | group.users.remove(user) 126 | else: 127 | self.log.warning("User %s already not in group %s", user.name, name) 128 | self.db.commit() 129 | self.write(json.dumps(self.group_model(group))) 130 | 131 | 132 | default_handlers = [ 133 | (r"/api/groups", GroupListAPIHandler), 134 | (r"/api/groups/([^/]+)", GroupAPIHandler), 135 | (r"/api/groups/([^/]+)/users", GroupUsersAPIHandler), 136 | ] 137 | -------------------------------------------------------------------------------- /jupyterhub/apihandlers/hub.py: -------------------------------------------------------------------------------- 1 | """API handlers for administering the Hub itself""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | 6 | import json 7 | import sys 8 | 9 | from tornado import web 10 | from tornado.ioloop import IOLoop 11 | 12 | from ..utils import admin_only 13 | from .base import APIHandler 14 | from ..version import __version__ 15 | 16 | 17 | class ShutdownAPIHandler(APIHandler): 18 | 19 | @admin_only 20 | def post(self): 21 | """POST /api/shutdown triggers a clean shutdown 22 | 23 | POST (JSON) parameters: 24 | 25 | - servers: specify whether single-user servers should be terminated 26 | - proxy: specify whether the proxy should be terminated 27 | """ 28 | from ..app import JupyterHub 29 | app = JupyterHub.instance() 30 | 31 | data = self.get_json_body() 32 | if data: 33 | if 'proxy' in data: 34 | proxy = data['proxy'] 35 | if proxy not in {True, False}: 36 | raise web.HTTPError(400, "proxy must be true or false, got %r" % proxy) 37 | app.cleanup_proxy = proxy 38 | if 'servers' in data: 39 | servers = data['servers'] 40 | if servers not in {True, False}: 41 | raise web.HTTPError(400, "servers must be true or false, got %r" % servers) 42 | app.cleanup_servers = servers 43 | 44 | # finish the request 45 | self.set_status(202) 46 | self.finish(json.dumps({ 47 | "message": "Shutting down Hub" 48 | })) 49 | 50 | # stop the eventloop, which will trigger cleanup 51 | loop = IOLoop.current() 52 | loop.add_callback(loop.stop) 53 | 54 | 55 | class RootAPIHandler(APIHandler): 56 | 57 | def get(self): 58 | """GET /api/ returns info about the Hub and its API. 59 | 60 | It is not an authenticated endpoint. 61 | 62 | For now, it just returns the version of JupyterHub itself. 63 | """ 64 | data = { 65 | 'version': __version__, 66 | } 67 | self.finish(json.dumps(data)) 68 | 69 | 70 | class InfoAPIHandler(APIHandler): 71 | 72 | @admin_only 73 | def get(self): 74 | """GET /api/info returns detailed info about the Hub and its API. 75 | 76 | It is not an authenticated endpoint. 77 | 78 | For now, it just returns the version of JupyterHub itself. 79 | """ 80 | def _class_info(typ): 81 | """info about a class (Spawner or Authenticator)""" 82 | info = { 83 | 'class': '{mod}.{name}'.format(mod=typ.__module__, name=typ.__name__), 84 | } 85 | pkg = typ.__module__.split('.')[0] 86 | try: 87 | version = sys.modules[pkg].__version__ 88 | except (KeyError, AttributeError): 89 | version = 'unknown' 90 | info['version'] = version 91 | return info 92 | 93 | data = { 94 | 'version': __version__, 95 | 'python': sys.version, 96 | 'sys_executable': sys.executable, 97 | 'spawner': _class_info(self.settings['spawner_class']), 98 | 'authenticator': _class_info(self.authenticator.__class__), 99 | } 100 | self.finish(json.dumps(data)) 101 | 102 | 103 | default_handlers = [ 104 | (r"/api/shutdown", ShutdownAPIHandler), 105 | (r"/api/?", RootAPIHandler), 106 | (r"/api/info", InfoAPIHandler), 107 | ] 108 | -------------------------------------------------------------------------------- /jupyterhub/apihandlers/proxy.py: -------------------------------------------------------------------------------- 1 | """Proxy handlers""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | 6 | import json 7 | 8 | from tornado import gen, web 9 | 10 | from .. import orm 11 | from ..utils import admin_only 12 | from .base import APIHandler 13 | 14 | class ProxyAPIHandler(APIHandler): 15 | 16 | @admin_only 17 | @gen.coroutine 18 | def get(self): 19 | """GET /api/proxy fetches the routing table 20 | 21 | This is the same as fetching the routing table directly from the proxy, 22 | but without clients needing to maintain separate 23 | """ 24 | routes = yield self.proxy.get_routes() 25 | self.write(json.dumps(routes)) 26 | 27 | @admin_only 28 | @gen.coroutine 29 | def post(self): 30 | """POST checks the proxy to ensure""" 31 | yield self.proxy.check_routes(self.users, self.services) 32 | 33 | 34 | @admin_only 35 | @gen.coroutine 36 | def patch(self): 37 | """PATCH updates the location of the proxy 38 | 39 | Can be used to notify the Hub that a new proxy is in charge 40 | """ 41 | if not self.request.body: 42 | raise web.HTTPError(400, "need JSON body") 43 | 44 | try: 45 | model = json.loads(self.request.body.decode('utf8', 'replace')) 46 | except ValueError: 47 | raise web.HTTPError(400, "Request body must be JSON dict") 48 | if not isinstance(model, dict): 49 | raise web.HTTPError(400, "Request body must be JSON dict") 50 | 51 | server = self.proxy.api_server 52 | if 'ip' in model: 53 | server.ip = model['ip'] 54 | if 'port' in model: 55 | server.port = model['port'] 56 | if 'protocol' in model: 57 | server.proto = model['protocol'] 58 | if 'auth_token' in model: 59 | self.proxy.auth_token = model['auth_token'] 60 | self.db.commit() 61 | self.log.info("Updated proxy at %s", server.bind_url) 62 | yield self.proxy.check_routes(self.users, self.services) 63 | 64 | 65 | 66 | default_handlers = [ 67 | (r"/api/proxy", ProxyAPIHandler), 68 | ] 69 | -------------------------------------------------------------------------------- /jupyterhub/apihandlers/services.py: -------------------------------------------------------------------------------- 1 | """Service handlers 2 | 3 | Currently GET-only, no actions can be taken to modify services. 4 | """ 5 | 6 | # Copyright (c) Jupyter Development Team. 7 | # Distributed under the terms of the Modified BSD License. 8 | 9 | import json 10 | 11 | from tornado import web 12 | 13 | from .. import orm 14 | from ..utils import admin_only 15 | from .base import APIHandler 16 | 17 | def service_model(service): 18 | """Produce the model for a service""" 19 | return { 20 | 'name': service.name, 21 | 'admin': service.admin, 22 | 'url': service.url, 23 | 'prefix': service.server.base_url if service.server else '', 24 | 'command': service.command, 25 | 'pid': service.proc.pid if service.proc else 0, 26 | } 27 | 28 | class ServiceListAPIHandler(APIHandler): 29 | @admin_only 30 | def get(self): 31 | data = {name: service_model(service) for name, service in self.services.items()} 32 | self.write(json.dumps(data)) 33 | 34 | 35 | def admin_or_self(method): 36 | """Decorator for restricting access to either the target service or admin""" 37 | def decorated_method(self, name): 38 | current = self.get_current_user() 39 | if current is None: 40 | raise web.HTTPError(403) 41 | if not current.admin: 42 | # not admin, maybe self 43 | if not isinstance(current, orm.Service): 44 | raise web.HTTPError(403) 45 | if current.name != name: 46 | raise web.HTTPError(403) 47 | # raise 404 if not found 48 | if name not in self.services: 49 | raise web.HTTPError(404) 50 | return method(self, name) 51 | return decorated_method 52 | 53 | class ServiceAPIHandler(APIHandler): 54 | 55 | @admin_or_self 56 | def get(self, name): 57 | service = self.services[name] 58 | self.write(json.dumps(service_model(service))) 59 | 60 | 61 | default_handlers = [ 62 | (r"/api/services", ServiceListAPIHandler), 63 | (r"/api/services/([^/]+)", ServiceAPIHandler), 64 | ] 65 | -------------------------------------------------------------------------------- /jupyterhub/dbutil.py: -------------------------------------------------------------------------------- 1 | """Database utilities for JupyterHub""" 2 | # Copyright (c) Jupyter Development Team. 3 | # Distributed under the terms of the Modified BSD License. 4 | 5 | # Based on pgcontents.utils.migrate, used under the Apache license. 6 | 7 | from contextlib import contextmanager 8 | import os 9 | from subprocess import check_call 10 | import sys 11 | from tempfile import TemporaryDirectory 12 | 13 | _here = os.path.abspath(os.path.dirname(__file__)) 14 | 15 | ALEMBIC_INI_TEMPLATE_PATH = os.path.join(_here, 'alembic.ini') 16 | ALEMBIC_DIR = os.path.join(_here, 'alembic') 17 | 18 | 19 | def write_alembic_ini(alembic_ini='alembic.ini', db_url='sqlite:///jupyterhub.sqlite'): 20 | """Write a complete alembic.ini from our template. 21 | 22 | Parameters 23 | ---------- 24 | 25 | alembic_ini: str 26 | path to the alembic.ini file that should be written. 27 | db_url: str 28 | The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`. 29 | """ 30 | with open(ALEMBIC_INI_TEMPLATE_PATH) as f: 31 | alembic_ini_tpl = f.read() 32 | 33 | with open(alembic_ini, 'w') as f: 34 | f.write( 35 | alembic_ini_tpl.format( 36 | alembic_dir=ALEMBIC_DIR, 37 | db_url=db_url, 38 | ) 39 | ) 40 | 41 | 42 | 43 | @contextmanager 44 | def _temp_alembic_ini(db_url): 45 | """Context manager for temporary JupyterHub alembic directory 46 | 47 | Temporarily write an alembic.ini file for use with alembic migration scripts. 48 | 49 | Context manager yields alembic.ini path. 50 | 51 | Parameters 52 | ---------- 53 | 54 | db_url: str 55 | The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`. 56 | 57 | Returns 58 | ------- 59 | 60 | alembic_ini: str 61 | The path to the temporary alembic.ini that we have created. 62 | This file will be cleaned up on exit from the context manager. 63 | """ 64 | with TemporaryDirectory() as td: 65 | alembic_ini = os.path.join(td, 'alembic.ini') 66 | write_alembic_ini(alembic_ini, db_url) 67 | yield alembic_ini 68 | 69 | 70 | def upgrade(db_url, revision='head'): 71 | """Upgrade the given database to revision. 72 | 73 | db_url: str 74 | The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`. 75 | revision: str [default: head] 76 | The alembic revision to upgrade to. 77 | """ 78 | with _temp_alembic_ini(db_url) as alembic_ini: 79 | check_call( 80 | ['alembic', '-c', alembic_ini, 'upgrade', revision] 81 | ) 82 | 83 | def _alembic(*args): 84 | """Run an alembic command with a temporary alembic.ini""" 85 | with _temp_alembic_ini('sqlite:///jupyterhub.sqlite') as alembic_ini: 86 | check_call( 87 | ['alembic', '-c', alembic_ini] + list(args) 88 | ) 89 | 90 | 91 | if __name__ == '__main__': 92 | import sys 93 | _alembic(*sys.argv[1:]) 94 | -------------------------------------------------------------------------------- /jupyterhub/emptyclass.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple empty class that returns itself for all functions called on it. 3 | This allows us to call any method of any name on this, and it'll return another 4 | instance of itself that'll allow any method to be called on it. 5 | 6 | Primarily used to mock out the statsd client when statsd is not being used 7 | """ 8 | class EmptyClass: 9 | def empty_function(self, *args, **kwargs): 10 | return self 11 | 12 | def __getattr__(self, attr): 13 | return self.empty_function 14 | -------------------------------------------------------------------------------- /jupyterhub/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | from .login import * 3 | 4 | from . import base, pages, login 5 | 6 | default_handlers = [] 7 | for mod in (base, pages, login): 8 | default_handlers.extend(mod.default_handlers) 9 | -------------------------------------------------------------------------------- /jupyterhub/handlers/login.py: -------------------------------------------------------------------------------- 1 | """HTTP Handlers for the hub server""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | 6 | from urllib.parse import urlparse 7 | 8 | from tornado.escape import url_escape 9 | from tornado import gen 10 | 11 | from .base import BaseHandler 12 | 13 | 14 | class LogoutHandler(BaseHandler): 15 | """Log a user out by clearing their login cookie.""" 16 | def get(self): 17 | user = self.get_current_user() 18 | if user: 19 | self.log.info("User logged out: %s", user.name) 20 | self.clear_login_cookie() 21 | for name in user.other_user_cookies: 22 | self.clear_login_cookie(name) 23 | user.other_user_cookies = set([]) 24 | self.statsd.incr('logout') 25 | self.redirect(self.hub.server.base_url, permanent=False) 26 | 27 | 28 | class LoginHandler(BaseHandler): 29 | """Render the login page.""" 30 | 31 | def _render(self, login_error=None, username=None): 32 | return self.render_template('login.html', 33 | next=url_escape(self.get_argument('next', default='')), 34 | username=username, 35 | login_error=login_error, 36 | custom_html=self.authenticator.custom_html, 37 | login_url=self.settings['login_url'], 38 | ) 39 | 40 | def get(self): 41 | self.statsd.incr('login.request') 42 | next_url = self.get_argument('next', '') 43 | if (next_url + '/').startswith('%s://%s/' % (self.request.protocol, self.request.host)): 44 | # treat absolute URLs for our host as absolute paths: 45 | next_url = urlparse(next_url).path 46 | elif not next_url.startswith('/'): 47 | # disallow non-absolute next URLs (e.g. full URLs to other hosts) 48 | next_url = '' 49 | user = self.get_current_user() 50 | if user: 51 | if not next_url: 52 | if user.running: 53 | next_url = user.url 54 | else: 55 | next_url = self.hub.server.base_url 56 | # set new login cookie 57 | # because single-user cookie may have been cleared or incorrect 58 | self.set_login_cookie(self.get_current_user()) 59 | self.redirect(next_url, permanent=False) 60 | else: 61 | username = self.get_argument('username', default='') 62 | self.finish(self._render(username=username)) 63 | 64 | @gen.coroutine 65 | def post(self): 66 | # parse the arguments dict 67 | data = {} 68 | for arg in self.request.arguments: 69 | data[arg] = self.get_argument(arg) 70 | 71 | user = yield self.login_user(data) 72 | if user: 73 | next_url = self.get_argument('next', default='') 74 | if not next_url.startswith('/'): 75 | next_url = '' 76 | next_url = next_url or self.hub.server.base_url 77 | self.redirect(next_url) 78 | self.log.info("User logged in: %s", user.name) 79 | else: 80 | html = self._render( 81 | login_error='Invalid username or password', 82 | username=data.get('username', 'unknown user'), 83 | ) 84 | self.finish(html) 85 | 86 | 87 | # /login renders the login page or the "Login with..." link, 88 | # so it should always be registered. 89 | # /logout clears cookies. 90 | default_handlers = [ 91 | (r"/login", LoginHandler), 92 | (r"/logout", LogoutHandler), 93 | ] 94 | -------------------------------------------------------------------------------- /jupyterhub/handlers/static.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import os 5 | from tornado.web import StaticFileHandler 6 | 7 | class CacheControlStaticFilesHandler(StaticFileHandler): 8 | """StaticFileHandler subclass that sets Cache-Control: no-cache without `?v=` 9 | 10 | rather than relying on default browser cache behavior. 11 | """ 12 | def compute_etag(self): 13 | return None 14 | 15 | def set_extra_headers(self, path): 16 | if "v" not in self.request.arguments: 17 | self.add_header("Cache-Control", "no-cache") 18 | 19 | class LogoHandler(StaticFileHandler): 20 | """A singular handler for serving the logo.""" 21 | def get(self): 22 | return super().get('') 23 | 24 | @classmethod 25 | def get_absolute_path(cls, root, path): 26 | """We only serve one file, ignore relative path""" 27 | return os.path.abspath(root) 28 | 29 | -------------------------------------------------------------------------------- /jupyterhub/log.py: -------------------------------------------------------------------------------- 1 | """logging utilities""" 2 | # Copyright (c) Jupyter Development Team. 3 | # Distributed under the terms of the Modified BSD License. 4 | 5 | import json 6 | import traceback 7 | 8 | from tornado.log import LogFormatter, access_log 9 | from tornado.web import StaticFileHandler 10 | 11 | 12 | def coroutine_traceback(typ, value, tb): 13 | """Scrub coroutine frames from a traceback 14 | 15 | Coroutine tracebacks have a bunch of identical uninformative frames at each yield point. 16 | This removes those extra frames, so tracebacks should be easier to read. 17 | This might be a horrible idea. 18 | 19 | Returns a list of strings (like traceback.format_tb) 20 | """ 21 | all_frames = traceback.extract_tb(tb) 22 | useful_frames = [] 23 | for frame in all_frames: 24 | if frame[0] == '' and frame[2] == 'raise_exc_info': 25 | continue 26 | # start out conservative with filename + function matching 27 | # maybe just filename matching would be sufficient 28 | elif frame[0].endswith('tornado/gen.py') and frame[2] in {'run', 'wrapper'}: 29 | continue 30 | elif frame[0].endswith('tornado/concurrent.py') and frame[2] == 'result': 31 | continue 32 | useful_frames.append(frame) 33 | tb_list = ['Traceback (most recent call last):\n'] 34 | tb_list.extend(traceback.format_list(useful_frames)) 35 | tb_list.extend(traceback.format_exception_only(typ, value)) 36 | return tb_list 37 | 38 | 39 | class CoroutineLogFormatter(LogFormatter): 40 | """Log formatter that scrubs coroutine frames""" 41 | def formatException(self, exc_info): 42 | return ''.join(coroutine_traceback(*exc_info)) 43 | 44 | 45 | def _scrub_uri(uri): 46 | """scrub auth info from uri""" 47 | if '/api/authorizations/cookie/' in uri or '/api/authorizations/token/' in uri: 48 | uri = uri.rsplit('/', 1)[0] + '/[secret]' 49 | return uri 50 | 51 | 52 | def _scrub_headers(headers): 53 | """scrub auth info from headers""" 54 | headers = dict(headers) 55 | if 'Authorization' in headers: 56 | auth = headers['Authorization'] 57 | if auth.startswith('token '): 58 | headers['Authorization'] = 'token [secret]' 59 | return headers 60 | 61 | 62 | # log_request adapted from IPython (BSD) 63 | 64 | def log_request(handler): 65 | """log a bit more information about each request than tornado's default 66 | 67 | - move static file get success to debug-level (reduces noise) 68 | - get proxied IP instead of proxy IP 69 | - log referer for redirect and failed requests 70 | - log user-agent for failed requests 71 | """ 72 | status = handler.get_status() 73 | request = handler.request 74 | if status == 304 or (status < 300 and isinstance(handler, StaticFileHandler)): 75 | # static-file success and 304 Found are debug-level 76 | log_method = access_log.debug 77 | elif status < 400: 78 | log_method = access_log.info 79 | elif status < 500: 80 | log_method = access_log.warning 81 | else: 82 | log_method = access_log.error 83 | 84 | uri = _scrub_uri(request.uri) 85 | headers = _scrub_headers(request.headers) 86 | 87 | request_time = 1000.0 * handler.request.request_time() 88 | user = handler.get_current_user() 89 | ns = dict( 90 | status=status, 91 | method=request.method, 92 | ip=request.remote_ip, 93 | uri=uri, 94 | request_time=request_time, 95 | user=user.name if user else '' 96 | ) 97 | msg = "{status} {method} {uri} ({user}@{ip}) {request_time:.2f}ms" 98 | if status >= 500 and status != 502: 99 | log_method(json.dumps(headers, indent=2)) 100 | log_method(msg.format(**ns)) 101 | 102 | -------------------------------------------------------------------------------- /jupyterhub/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantopian/jupyterhub/a8bd7a697e326274187753fabbb8b30fa18bd79b/jupyterhub/services/__init__.py -------------------------------------------------------------------------------- /jupyterhub/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantopian/jupyterhub/a8bd7a697e326274187753fabbb8b30fa18bd79b/jupyterhub/tests/__init__.py -------------------------------------------------------------------------------- /jupyterhub/tests/conftest.py: -------------------------------------------------------------------------------- 1 | """py.test fixtures""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | 6 | import logging 7 | from getpass import getuser 8 | from subprocess import TimeoutExpired 9 | import time 10 | from unittest import mock 11 | from pytest import fixture, yield_fixture, raises 12 | from tornado import ioloop 13 | 14 | from .. import orm 15 | from ..utils import random_port 16 | 17 | from .mocking import MockHub 18 | from .test_services import mockservice_cmd 19 | 20 | import jupyterhub.services.service 21 | 22 | # global db session object 23 | _db = None 24 | 25 | @fixture 26 | def db(): 27 | """Get a db session""" 28 | global _db 29 | if _db is None: 30 | _db = orm.new_session_factory('sqlite:///:memory:', echo=True)() 31 | user = orm.User( 32 | name=getuser(), 33 | server=orm.Server(), 34 | ) 35 | hub = orm.Hub( 36 | server=orm.Server(), 37 | ) 38 | _db.add(user) 39 | _db.add(hub) 40 | _db.commit() 41 | return _db 42 | 43 | 44 | @fixture 45 | def io_loop(): 46 | """Get the current IOLoop""" 47 | loop = ioloop.IOLoop() 48 | loop.make_current() 49 | return loop 50 | 51 | 52 | @fixture(scope='module') 53 | def app(request): 54 | app = MockHub.instance(log_level=logging.DEBUG) 55 | app.start([]) 56 | def fin(): 57 | MockHub.clear_instance() 58 | app.stop() 59 | request.addfinalizer(fin) 60 | return app 61 | 62 | 63 | # mock services for testing. 64 | # Shorter intervals, etc. 65 | class MockServiceSpawner(jupyterhub.services.service._ServiceSpawner): 66 | poll_interval = 1 67 | 68 | 69 | def _mockservice(request, app, url=False): 70 | name = 'mock-service' 71 | spec = { 72 | 'name': name, 73 | 'command': mockservice_cmd, 74 | 'admin': True, 75 | } 76 | if url: 77 | spec['url'] = 'http://127.0.0.1:%i' % random_port(), 78 | 79 | with mock.patch.object(jupyterhub.services.service, '_ServiceSpawner', MockServiceSpawner): 80 | app.services = [{ 81 | 'name': name, 82 | 'command': mockservice_cmd, 83 | 'url': 'http://127.0.0.1:%i' % random_port(), 84 | 'admin': True, 85 | }] 86 | app.init_services() 87 | app.io_loop.add_callback(app.proxy.add_all_services, app._service_map) 88 | assert name in app._service_map 89 | service = app._service_map[name] 90 | app.io_loop.add_callback(service.start) 91 | request.addfinalizer(service.stop) 92 | for i in range(20): 93 | if not getattr(service, 'proc', False): 94 | time.sleep(0.2) 95 | # ensure process finishes starting 96 | with raises(TimeoutExpired): 97 | service.proc.wait(1) 98 | return service 99 | 100 | @yield_fixture 101 | def mockservice(request, app): 102 | yield _mockservice(request, app, url=False) 103 | 104 | @yield_fixture 105 | def mockservice_url(request, app): 106 | yield _mockservice(request, app, url=True) 107 | 108 | @yield_fixture 109 | def no_patience(app): 110 | """Set slow-spawning timeouts to zero""" 111 | with mock.patch.dict(app.tornado_application.settings, 112 | {'slow_spawn_timeout': 0, 113 | 'slow_stop_timeout': 0}): 114 | yield 115 | 116 | -------------------------------------------------------------------------------- /jupyterhub/tests/mockservice.py: -------------------------------------------------------------------------------- 1 | """Mock service for testing 2 | 3 | basic HTTP Server that echos URLs back, 4 | and allow retrieval of sys.argv. 5 | """ 6 | 7 | import argparse 8 | import json 9 | import os 10 | import sys 11 | from urllib.parse import urlparse 12 | 13 | import requests 14 | from tornado import web, httpserver, ioloop 15 | 16 | from jupyterhub.services.auth import HubAuthenticated 17 | 18 | class EchoHandler(web.RequestHandler): 19 | def get(self): 20 | self.write(self.request.path) 21 | 22 | 23 | class EnvHandler(web.RequestHandler): 24 | def get(self): 25 | self.set_header('Content-Type', 'application/json') 26 | self.write(json.dumps(dict(os.environ))) 27 | 28 | 29 | class APIHandler(web.RequestHandler): 30 | def get(self, path): 31 | api_token = os.environ['JUPYTERHUB_API_TOKEN'] 32 | api_url = os.environ['JUPYTERHUB_API_URL'] 33 | r = requests.get(api_url + path, headers={ 34 | 'Authorization': 'token %s' % api_token 35 | }) 36 | r.raise_for_status() 37 | self.set_header('Content-Type', 'application/json') 38 | self.write(r.text) 39 | 40 | class WhoAmIHandler(HubAuthenticated, web.RequestHandler): 41 | 42 | @web.authenticated 43 | def get(self): 44 | self.write(self.get_current_user()) 45 | 46 | 47 | def main(): 48 | if os.environ['JUPYTERHUB_SERVICE_URL']: 49 | url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL']) 50 | app = web.Application([ 51 | (r'.*/env', EnvHandler), 52 | (r'.*/api/(.*)', APIHandler), 53 | (r'.*/whoami/?', WhoAmIHandler), 54 | (r'.*', EchoHandler), 55 | ]) 56 | 57 | server = httpserver.HTTPServer(app) 58 | server.listen(url.port, url.hostname) 59 | try: 60 | ioloop.IOLoop.instance().start() 61 | except KeyboardInterrupt: 62 | print('\nInterrupted') 63 | 64 | if __name__ == '__main__': 65 | from tornado.options import parse_command_line 66 | parse_command_line() 67 | main() 68 | -------------------------------------------------------------------------------- /jupyterhub/tests/mocksu.py: -------------------------------------------------------------------------------- 1 | """Mock single-user server for testing 2 | 3 | basic HTTP Server that echos URLs back, 4 | and allow retrieval of sys.argv. 5 | """ 6 | 7 | import argparse 8 | import json 9 | import sys 10 | 11 | from tornado import web, httpserver, ioloop 12 | 13 | 14 | class EchoHandler(web.RequestHandler): 15 | def get(self): 16 | self.write(self.request.path) 17 | 18 | class ArgsHandler(web.RequestHandler): 19 | def get(self): 20 | self.write(json.dumps(sys.argv)) 21 | 22 | def main(args): 23 | 24 | app = web.Application([ 25 | (r'.*/args', ArgsHandler), 26 | (r'.*', EchoHandler), 27 | ]) 28 | 29 | server = httpserver.HTTPServer(app) 30 | server.listen(args.port) 31 | try: 32 | ioloop.IOLoop.instance().start() 33 | except KeyboardInterrupt: 34 | print('\nInterrupted') 35 | 36 | if __name__ == '__main__': 37 | parser = argparse.ArgumentParser() 38 | parser.add_argument('--port', type=int) 39 | args, extra = parser.parse_known_args() 40 | main(args) -------------------------------------------------------------------------------- /jupyterhub/tests/old-jupyterhub.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantopian/jupyterhub/a8bd7a697e326274187753fabbb8b30fa18bd79b/jupyterhub/tests/old-jupyterhub.sqlite -------------------------------------------------------------------------------- /jupyterhub/tests/test_app.py: -------------------------------------------------------------------------------- 1 | """Test the JupyterHub entry point""" 2 | 3 | import binascii 4 | import os 5 | import re 6 | import sys 7 | from subprocess import check_output, Popen, PIPE 8 | from tempfile import NamedTemporaryFile, TemporaryDirectory 9 | from unittest.mock import patch 10 | 11 | import pytest 12 | 13 | from .mocking import MockHub 14 | from .. import orm 15 | 16 | def test_help_all(): 17 | out = check_output([sys.executable, '-m', 'jupyterhub', '--help-all']).decode('utf8', 'replace') 18 | assert '--ip' in out 19 | assert '--JupyterHub.ip' in out 20 | 21 | def test_token_app(): 22 | cmd = [sys.executable, '-m', 'jupyterhub', 'token'] 23 | out = check_output(cmd + ['--help-all']).decode('utf8', 'replace') 24 | with TemporaryDirectory() as td: 25 | with open(os.path.join(td, 'jupyterhub_config.py'), 'w') as f: 26 | f.write("c.Authenticator.admin_users={'user'}") 27 | out = check_output(cmd + ['user'], cwd=td).decode('utf8', 'replace').strip() 28 | assert re.match(r'^[a-z0-9]+$', out) 29 | 30 | def test_generate_config(): 31 | with NamedTemporaryFile(prefix='jupyterhub_config', suffix='.py') as tf: 32 | cfg_file = tf.name 33 | with open(cfg_file, 'w') as f: 34 | f.write("c.A = 5") 35 | p = Popen([sys.executable, '-m', 'jupyterhub', 36 | '--generate-config', '-f', cfg_file], 37 | stdout=PIPE, stdin=PIPE) 38 | out, _ = p.communicate(b'n') 39 | out = out.decode('utf8', 'replace') 40 | assert os.path.exists(cfg_file) 41 | with open(cfg_file) as f: 42 | cfg_text = f.read() 43 | assert cfg_text == 'c.A = 5' 44 | 45 | p = Popen([sys.executable, '-m', 'jupyterhub', 46 | '--generate-config', '-f', cfg_file], 47 | stdout=PIPE, stdin=PIPE) 48 | out, _ = p.communicate(b'x\ny') 49 | out = out.decode('utf8', 'replace') 50 | assert os.path.exists(cfg_file) 51 | with open(cfg_file) as f: 52 | cfg_text = f.read() 53 | os.remove(cfg_file) 54 | assert cfg_file in out 55 | assert 'Spawner.cmd' in cfg_text 56 | assert 'Authenticator.whitelist' in cfg_text 57 | 58 | def test_init_tokens(io_loop): 59 | with TemporaryDirectory() as td: 60 | db_file = os.path.join(td, 'jupyterhub.sqlite') 61 | tokens = { 62 | 'super-secret-token': 'alyx', 63 | 'also-super-secret': 'gordon', 64 | 'boagasdfasdf': 'chell', 65 | } 66 | app = MockHub(db_url=db_file, api_tokens=tokens) 67 | io_loop.run_sync(lambda : app.initialize([])) 68 | db = app.db 69 | for token, username in tokens.items(): 70 | api_token = orm.APIToken.find(db, token) 71 | assert api_token is not None 72 | user = api_token.user 73 | assert user.name == username 74 | 75 | # simulate second startup, reloading same tokens: 76 | app = MockHub(db_url=db_file, api_tokens=tokens) 77 | io_loop.run_sync(lambda : app.initialize([])) 78 | db = app.db 79 | for token, username in tokens.items(): 80 | api_token = orm.APIToken.find(db, token) 81 | assert api_token is not None 82 | user = api_token.user 83 | assert user.name == username 84 | 85 | # don't allow failed token insertion to create users: 86 | tokens['short'] = 'gman' 87 | app = MockHub(db_url=db_file, api_tokens=tokens) 88 | with pytest.raises(ValueError): 89 | io_loop.run_sync(lambda : app.initialize([])) 90 | assert orm.User.find(app.db, 'gman') is None 91 | 92 | 93 | def test_write_cookie_secret(tmpdir): 94 | secret_path = str(tmpdir.join('cookie_secret')) 95 | hub = MockHub(cookie_secret_file=secret_path) 96 | hub.init_secrets() 97 | assert os.path.exists(secret_path) 98 | assert os.stat(secret_path).st_mode & 0o600 99 | assert not os.stat(secret_path).st_mode & 0o177 100 | 101 | 102 | def test_cookie_secret_permissions(tmpdir): 103 | secret_file = tmpdir.join('cookie_secret') 104 | secret_path = str(secret_file) 105 | secret = os.urandom(1024) 106 | secret_file.write(binascii.b2a_base64(secret)) 107 | hub = MockHub(cookie_secret_file=secret_path) 108 | 109 | # raise with public secret file 110 | os.chmod(secret_path, 0o664) 111 | with pytest.raises(SystemExit): 112 | hub.init_secrets() 113 | 114 | # ok with same file, proper permissions 115 | os.chmod(secret_path, 0o660) 116 | hub.init_secrets() 117 | assert hub.cookie_secret == secret 118 | 119 | 120 | def test_cookie_secret_content(tmpdir): 121 | secret_file = tmpdir.join('cookie_secret') 122 | secret_file.write('not base 64: uñiço∂e') 123 | secret_path = str(secret_file) 124 | os.chmod(secret_path, 0o660) 125 | hub = MockHub(cookie_secret_file=secret_path) 126 | with pytest.raises(SystemExit): 127 | hub.init_secrets() 128 | 129 | 130 | def test_cookie_secret_env(tmpdir): 131 | hub = MockHub(cookie_secret_file=str(tmpdir.join('cookie_secret'))) 132 | 133 | with patch.dict(os.environ, {'JPY_COOKIE_SECRET': 'not hex'}): 134 | with pytest.raises(ValueError): 135 | hub.init_secrets() 136 | 137 | with patch.dict(os.environ, {'JPY_COOKIE_SECRET': 'abc123'}): 138 | hub.init_secrets() 139 | assert hub.cookie_secret == binascii.a2b_hex('abc123') 140 | assert not os.path.exists(hub.cookie_secret_file) 141 | 142 | 143 | def test_load_groups(io_loop): 144 | to_load = { 145 | 'blue': ['cyclops', 'rogue', 'wolverine'], 146 | 'gold': ['storm', 'jean-grey', 'colossus'], 147 | } 148 | hub = MockHub(load_groups=to_load) 149 | hub.init_db() 150 | io_loop.run_sync(hub.init_users) 151 | hub.init_groups() 152 | db = hub.db 153 | blue = orm.Group.find(db, name='blue') 154 | assert blue is not None 155 | assert sorted([ u.name for u in blue.users ]) == sorted(to_load['blue']) 156 | gold = orm.Group.find(db, name='gold') 157 | assert gold is not None 158 | assert sorted([ u.name for u in gold.users ]) == sorted(to_load['gold']) 159 | -------------------------------------------------------------------------------- /jupyterhub/tests/test_db.py: -------------------------------------------------------------------------------- 1 | from glob import glob 2 | import os 3 | import shutil 4 | 5 | from sqlalchemy.exc import OperationalError 6 | from pytest import raises 7 | 8 | from ..dbutil import upgrade 9 | from ..app import NewToken, UpgradeDB, JupyterHub 10 | 11 | 12 | here = os.path.dirname(__file__) 13 | old_db = os.path.join(here, 'old-jupyterhub.sqlite') 14 | 15 | def generate_old_db(path): 16 | db_path = os.path.join(path, "jupyterhub.sqlite") 17 | print(old_db, db_path) 18 | shutil.copy(old_db, db_path) 19 | return 'sqlite:///%s' % db_path 20 | 21 | def test_upgrade(tmpdir): 22 | print(tmpdir) 23 | db_url = generate_old_db(str(tmpdir)) 24 | print(db_url) 25 | upgrade(db_url) 26 | 27 | def test_upgrade_entrypoint(tmpdir, io_loop): 28 | generate_old_db(str(tmpdir)) 29 | tmpdir.chdir() 30 | tokenapp = NewToken() 31 | tokenapp.initialize(['kaylee']) 32 | with raises(OperationalError): 33 | tokenapp.start() 34 | 35 | sqlite_files = glob(os.path.join(str(tmpdir), 'jupyterhub.sqlite*')) 36 | assert len(sqlite_files) == 1 37 | 38 | upgradeapp = UpgradeDB() 39 | io_loop.run_sync(lambda : upgradeapp.initialize([])) 40 | upgradeapp.start() 41 | 42 | # check that backup was created: 43 | sqlite_files = glob(os.path.join(str(tmpdir), 'jupyterhub.sqlite*')) 44 | assert len(sqlite_files) == 2 45 | 46 | # run tokenapp again, it should work 47 | tokenapp.start() 48 | -------------------------------------------------------------------------------- /jupyterhub/tests/test_orm.py: -------------------------------------------------------------------------------- 1 | """Tests for the ORM bits""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | 6 | import pytest 7 | from tornado import gen 8 | 9 | from .. import orm 10 | from ..user import User 11 | from .mocking import MockSpawner 12 | 13 | 14 | def test_server(db): 15 | server = orm.Server() 16 | db.add(server) 17 | db.commit() 18 | assert server.ip == '' 19 | assert server.base_url == '/' 20 | assert server.proto == 'http' 21 | assert isinstance(server.port, int) 22 | assert isinstance(server.cookie_name, str) 23 | assert server.host == 'http://127.0.0.1:%i' % server.port 24 | assert server.url == server.host + '/' 25 | assert server.bind_url == 'http://*:%i/' % server.port 26 | server.ip = '127.0.0.1' 27 | assert server.host == 'http://127.0.0.1:%i' % server.port 28 | assert server.url == server.host + '/' 29 | 30 | 31 | def test_proxy(db): 32 | proxy = orm.Proxy( 33 | auth_token='abc-123', 34 | public_server=orm.Server( 35 | ip='192.168.1.1', 36 | port=8000, 37 | ), 38 | api_server=orm.Server( 39 | ip='127.0.0.1', 40 | port=8001, 41 | ), 42 | ) 43 | db.add(proxy) 44 | db.commit() 45 | assert proxy.public_server.ip == '192.168.1.1' 46 | assert proxy.api_server.ip == '127.0.0.1' 47 | assert proxy.auth_token == 'abc-123' 48 | 49 | 50 | def test_hub(db): 51 | hub = orm.Hub( 52 | server=orm.Server( 53 | ip = '1.2.3.4', 54 | port = 1234, 55 | base_url='/hubtest/', 56 | ), 57 | 58 | ) 59 | db.add(hub) 60 | db.commit() 61 | assert hub.server.ip == '1.2.3.4' 62 | hub.server.port == 1234 63 | assert hub.api_url == 'http://1.2.3.4:1234/hubtest/api' 64 | 65 | 66 | def test_user(db): 67 | user = orm.User(name='kaylee', 68 | server=orm.Server(), 69 | state={'pid': 4234}, 70 | ) 71 | db.add(user) 72 | db.commit() 73 | assert user.name == 'kaylee' 74 | assert user.server.ip == '' 75 | assert user.state == {'pid': 4234} 76 | 77 | found = orm.User.find(db, 'kaylee') 78 | assert found.name == user.name 79 | found = orm.User.find(db, 'badger') 80 | assert found is None 81 | 82 | 83 | def test_tokens(db): 84 | user = orm.User(name='inara') 85 | db.add(user) 86 | db.commit() 87 | token = user.new_api_token() 88 | assert any(t.match(token) for t in user.api_tokens) 89 | user.new_api_token() 90 | assert len(user.api_tokens) == 2 91 | found = orm.APIToken.find(db, token=token) 92 | assert found.match(token) 93 | assert found.user is user 94 | assert found.service is None 95 | found = orm.APIToken.find(db, 'something else') 96 | assert found is None 97 | 98 | secret = 'super-secret-preload-token' 99 | token = user.new_api_token(secret) 100 | assert token == secret 101 | assert len(user.api_tokens) == 3 102 | 103 | # raise ValueError on collision 104 | with pytest.raises(ValueError): 105 | user.new_api_token(token) 106 | assert len(user.api_tokens) == 3 107 | 108 | 109 | def test_service_tokens(db): 110 | service = orm.Service(name='secret') 111 | db.add(service) 112 | db.commit() 113 | token = service.new_api_token() 114 | assert any(t.match(token) for t in service.api_tokens) 115 | service.new_api_token() 116 | assert len(service.api_tokens) == 2 117 | found = orm.APIToken.find(db, token=token) 118 | assert found.match(token) 119 | assert found.user is None 120 | assert found.service is service 121 | service2 = orm.Service(name='secret') 122 | db.add(service) 123 | db.commit() 124 | assert service2.id != service.id 125 | 126 | 127 | def test_service_server(db): 128 | service = orm.Service(name='has_servers') 129 | db.add(service) 130 | db.commit() 131 | 132 | assert service.server is None 133 | server = service.server = orm.Server() 134 | assert service 135 | assert server.id is None 136 | db.commit() 137 | assert isinstance(server.id, int) 138 | 139 | 140 | def test_token_find(db): 141 | service = db.query(orm.Service).first() 142 | user = db.query(orm.User).first() 143 | service_token = service.new_api_token() 144 | user_token = user.new_api_token() 145 | with pytest.raises(ValueError): 146 | orm.APIToken.find(db, 'irrelevant', kind='richard') 147 | # no kind, find anything 148 | found = orm.APIToken.find(db, token=user_token) 149 | assert found 150 | assert found.match(user_token) 151 | found = orm.APIToken.find(db, token=service_token) 152 | assert found 153 | assert found.match(service_token) 154 | 155 | # kind=user, only find user tokens 156 | found = orm.APIToken.find(db, token=user_token, kind='user') 157 | assert found 158 | assert found.match(user_token) 159 | found = orm.APIToken.find(db, token=service_token, kind='user') 160 | assert found is None 161 | 162 | # kind=service, only find service tokens 163 | found = orm.APIToken.find(db, token=service_token, kind='service') 164 | assert found 165 | assert found.match(service_token) 166 | found = orm.APIToken.find(db, token=user_token, kind='service') 167 | assert found is None 168 | 169 | 170 | def test_spawn_fails(db, io_loop): 171 | orm_user = orm.User(name='aeofel') 172 | db.add(orm_user) 173 | db.commit() 174 | 175 | class BadSpawner(MockSpawner): 176 | @gen.coroutine 177 | def start(self): 178 | raise RuntimeError("Split the party") 179 | 180 | user = User(orm_user, { 181 | 'spawner_class': BadSpawner, 182 | 'config': None, 183 | }) 184 | 185 | with pytest.raises(Exception) as exc: 186 | io_loop.run_sync(user.spawn) 187 | assert user.server is None 188 | assert not user.running 189 | 190 | 191 | def test_groups(db): 192 | user = orm.User.find(db, name='aeofel') 193 | db.add(user) 194 | 195 | group = orm.Group(name='lives') 196 | db.add(group) 197 | db.commit() 198 | assert group.users == [] 199 | assert user.groups == [] 200 | group.users.append(user) 201 | db.commit() 202 | assert group.users == [user] 203 | assert user.groups == [group] 204 | -------------------------------------------------------------------------------- /jupyterhub/tests/test_proxy.py: -------------------------------------------------------------------------------- 1 | """Test a proxy being started before the Hub""" 2 | 3 | import json 4 | import os 5 | from queue import Queue 6 | from subprocess import Popen 7 | from urllib.parse import urlparse, unquote 8 | 9 | from .. import orm 10 | from .mocking import MockHub 11 | from .test_api import api_request 12 | from ..utils import wait_for_http_server, url_path_join as ujoin 13 | 14 | def test_external_proxy(request, io_loop): 15 | """Test a proxy started before the Hub""" 16 | auth_token='secret!' 17 | proxy_ip = '127.0.0.1' 18 | proxy_port = 54321 19 | 20 | app = MockHub.instance( 21 | proxy_api_ip=proxy_ip, 22 | proxy_api_port=proxy_port, 23 | proxy_auth_token=auth_token, 24 | ) 25 | def fin(): 26 | MockHub.clear_instance() 27 | app.stop() 28 | request.addfinalizer(fin) 29 | env = os.environ.copy() 30 | env['CONFIGPROXY_AUTH_TOKEN'] = auth_token 31 | cmd = app.proxy_cmd + [ 32 | '--ip', app.ip, 33 | '--port', str(app.port), 34 | '--api-ip', proxy_ip, 35 | '--api-port', str(proxy_port), 36 | '--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port), 37 | ] 38 | if app.subdomain_host: 39 | cmd.append('--host-routing') 40 | proxy = Popen(cmd, env=env) 41 | def _cleanup_proxy(): 42 | if proxy.poll() is None: 43 | proxy.terminate() 44 | request.addfinalizer(_cleanup_proxy) 45 | 46 | def wait_for_proxy(): 47 | io_loop.run_sync(lambda : wait_for_http_server( 48 | 'http://%s:%i' % (proxy_ip, proxy_port))) 49 | wait_for_proxy() 50 | 51 | app.start([]) 52 | 53 | assert app.proxy_process is None 54 | 55 | routes = io_loop.run_sync(app.proxy.get_routes) 56 | assert list(routes.keys()) == ['/'] 57 | 58 | # add user 59 | name = 'river' 60 | r = api_request(app, 'users', name, method='post') 61 | r.raise_for_status() 62 | r = api_request(app, 'users', name, 'server', method='post') 63 | r.raise_for_status() 64 | 65 | routes = io_loop.run_sync(app.proxy.get_routes) 66 | user_path = unquote(ujoin(app.base_url, 'user/river')) 67 | if app.subdomain_host: 68 | domain = urlparse(app.subdomain_host).hostname 69 | user_path = '/%s.%s' % (name, domain) + user_path 70 | assert sorted(routes.keys()) == ['/', user_path] 71 | 72 | # teardown the proxy and start a new one in the same place 73 | proxy.terminate() 74 | proxy = Popen(cmd, env=env) 75 | wait_for_proxy() 76 | 77 | routes = io_loop.run_sync(app.proxy.get_routes) 78 | assert list(routes.keys()) == ['/'] 79 | 80 | # poke the server to update the proxy 81 | r = api_request(app, 'proxy', method='post') 82 | r.raise_for_status() 83 | 84 | # check that the routes are correct 85 | routes = io_loop.run_sync(app.proxy.get_routes) 86 | assert sorted(routes.keys()) == ['/', user_path] 87 | 88 | # teardown the proxy again, and start a new one with different auth and port 89 | proxy.terminate() 90 | new_auth_token = 'different!' 91 | env['CONFIGPROXY_AUTH_TOKEN'] = new_auth_token 92 | proxy_port = 55432 93 | cmd = app.proxy_cmd + [ 94 | '--ip', app.ip, 95 | '--port', str(app.port), 96 | '--api-ip', app.proxy_api_ip, 97 | '--api-port', str(proxy_port), 98 | '--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port), 99 | ] 100 | if app.subdomain_host: 101 | cmd.append('--host-routing') 102 | proxy = Popen(cmd, env=env) 103 | wait_for_proxy() 104 | 105 | # tell the hub where the new proxy is 106 | r = api_request(app, 'proxy', method='patch', data=json.dumps({ 107 | 'port': proxy_port, 108 | 'protocol': 'http', 109 | 'ip': app.ip, 110 | 'auth_token': new_auth_token, 111 | })) 112 | r.raise_for_status() 113 | assert app.proxy.api_server.port == proxy_port 114 | 115 | # get updated auth token from main thread 116 | def get_app_proxy_token(): 117 | q = Queue() 118 | app.io_loop.add_callback(lambda : q.put(app.proxy.auth_token)) 119 | return q.get(timeout=2) 120 | 121 | assert get_app_proxy_token() == new_auth_token 122 | app.proxy.auth_token = new_auth_token 123 | 124 | # check that the routes are correct 125 | routes = io_loop.run_sync(app.proxy.get_routes) 126 | assert sorted(routes.keys()) == ['/', user_path] 127 | 128 | 129 | def test_check_routes(app, io_loop): 130 | proxy = app.proxy 131 | r = api_request(app, 'users/zoe', method='post') 132 | r.raise_for_status() 133 | r = api_request(app, 'users/zoe/server', method='post') 134 | r.raise_for_status() 135 | zoe = orm.User.find(app.db, 'zoe') 136 | assert zoe is not None 137 | zoe = app.users[zoe] 138 | before = sorted(io_loop.run_sync(app.proxy.get_routes)) 139 | assert unquote(zoe.proxy_path) in before 140 | io_loop.run_sync(lambda : app.proxy.check_routes(app.users, app._service_map)) 141 | io_loop.run_sync(lambda : proxy.delete_user(zoe)) 142 | during = sorted(io_loop.run_sync(app.proxy.get_routes)) 143 | assert unquote(zoe.proxy_path) not in during 144 | io_loop.run_sync(lambda : app.proxy.check_routes(app.users, app._service_map)) 145 | after = sorted(io_loop.run_sync(app.proxy.get_routes)) 146 | assert unquote(zoe.proxy_path) in after 147 | assert before == after 148 | 149 | 150 | def test_patch_proxy_bad_req(app): 151 | r = api_request(app, 'proxy', method='patch') 152 | assert r.status_code == 400 153 | r = api_request(app, 'proxy', method='patch', data='notjson') 154 | assert r.status_code == 400 155 | r = api_request(app, 'proxy', method='patch', data=json.dumps([])) 156 | assert r.status_code == 400 157 | -------------------------------------------------------------------------------- /jupyterhub/tests/test_services.py: -------------------------------------------------------------------------------- 1 | """Tests for services""" 2 | 3 | from binascii import hexlify 4 | from contextlib import contextmanager 5 | import os 6 | from subprocess import Popen 7 | import sys 8 | from threading import Event 9 | import time 10 | 11 | import requests 12 | from tornado import gen 13 | from tornado.ioloop import IOLoop 14 | 15 | 16 | from .mocking import public_url 17 | from ..utils import url_path_join, wait_for_http_server 18 | 19 | here = os.path.dirname(os.path.abspath(__file__)) 20 | mockservice_py = os.path.join(here, 'mockservice.py') 21 | mockservice_cmd = [sys.executable, mockservice_py] 22 | 23 | from ..utils import random_port 24 | 25 | 26 | @contextmanager 27 | def external_service(app, name='mockservice'): 28 | env = { 29 | 'JUPYTERHUB_API_TOKEN': hexlify(os.urandom(5)), 30 | 'JUPYTERHUB_SERVICE_NAME': name, 31 | 'JUPYTERHUB_API_URL': url_path_join(app.hub.server.url, 'api/'), 32 | 'JUPYTERHUB_SERVICE_URL': 'http://127.0.0.1:%i' % random_port(), 33 | } 34 | p = Popen(mockservice_cmd, env=env) 35 | IOLoop().run_sync(lambda : wait_for_http_server(env['JUPYTERHUB_SERVICE_URL'])) 36 | try: 37 | yield env 38 | finally: 39 | p.terminate() 40 | 41 | 42 | def test_managed_service(app, mockservice): 43 | service = mockservice 44 | proc = service.proc 45 | first_pid = proc.pid 46 | assert proc.poll() is None 47 | # shut it down: 48 | proc.terminate() 49 | proc.wait(10) 50 | assert proc.poll() is not None 51 | # ensure Hub notices and brings it back up: 52 | for i in range(20): 53 | if service.proc is not proc: 54 | break 55 | else: 56 | time.sleep(0.2) 57 | 58 | assert service.proc.pid != first_pid 59 | assert service.proc.poll() is None 60 | 61 | 62 | def test_proxy_service(app, mockservice_url, io_loop): 63 | service = mockservice_url 64 | name = service.name 65 | routes = io_loop.run_sync(app.proxy.get_routes) 66 | url = public_url(app, service) + '/foo' 67 | r = requests.get(url, allow_redirects=False) 68 | path = '/services/{}/foo'.format(name) 69 | r.raise_for_status() 70 | assert r.status_code == 200 71 | assert r.text.endswith(path) 72 | 73 | 74 | def test_external_service(app, io_loop): 75 | name = 'external' 76 | with external_service(app, name=name) as env: 77 | app.services = [{ 78 | 'name': name, 79 | 'admin': True, 80 | 'url': env['JUPYTERHUB_SERVICE_URL'], 81 | 'api_token': env['JUPYTERHUB_API_TOKEN'], 82 | }] 83 | app.init_services() 84 | app.init_api_tokens() 85 | evt = Event() 86 | @gen.coroutine 87 | def add_services(): 88 | yield app.proxy.add_all_services(app._service_map) 89 | evt.set() 90 | app.io_loop.add_callback(add_services) 91 | assert evt.wait(10) 92 | service = app._service_map[name] 93 | url = public_url(app, service) + '/api/users' 94 | path = '/services/{}/api/users'.format(name) 95 | r = requests.get(url, allow_redirects=False) 96 | r.raise_for_status() 97 | assert r.status_code == 200 98 | resp = r.json() 99 | assert isinstance(resp, list) 100 | assert len(resp) >= 1 101 | assert isinstance(resp[0], dict) 102 | assert 'name' in resp[0] 103 | 104 | 105 | -------------------------------------------------------------------------------- /jupyterhub/tests/test_services_auth.py: -------------------------------------------------------------------------------- 1 | import json 2 | from queue import Queue 3 | import sys 4 | from threading import Thread 5 | import time 6 | from unittest import mock 7 | 8 | from pytest import raises 9 | import requests 10 | import requests_mock 11 | 12 | from tornado.ioloop import IOLoop 13 | from tornado.httpserver import HTTPServer 14 | from tornado.web import RequestHandler, Application, authenticated, HTTPError 15 | 16 | from ..services.auth import _ExpiringDict, HubAuth, HubAuthenticated 17 | from ..utils import url_path_join 18 | from .mocking import public_url 19 | 20 | # mock for sending monotonic counter way into the future 21 | monotonic_future = mock.patch('time.monotonic', lambda : sys.maxsize) 22 | 23 | def test_expiring_dict(): 24 | cache = _ExpiringDict(max_age=30) 25 | cache['key'] = 'cached value' 26 | assert 'key' in cache 27 | assert cache['key'] == 'cached value' 28 | 29 | with raises(KeyError): 30 | cache['nokey'] 31 | 32 | with monotonic_future: 33 | assert 'key' not in cache 34 | 35 | cache['key'] = 'cached value' 36 | assert 'key' in cache 37 | with monotonic_future: 38 | assert 'key' not in cache 39 | 40 | cache['key'] = 'cached value' 41 | assert 'key' in cache 42 | with monotonic_future: 43 | with raises(KeyError): 44 | cache['key'] 45 | 46 | cache['key'] = 'cached value' 47 | assert 'key' in cache 48 | with monotonic_future: 49 | assert cache.get('key', 'default') == 'default' 50 | 51 | cache.max_age = 0 52 | 53 | cache['key'] = 'cached value' 54 | assert 'key' in cache 55 | with monotonic_future: 56 | assert cache.get('key', 'default') == 'cached value' 57 | 58 | 59 | def test_hub_auth(): 60 | start = time.monotonic() 61 | auth = HubAuth(cookie_name='foo') 62 | mock_model = { 63 | 'name': 'onyxia' 64 | } 65 | url = url_path_join(auth.api_url, "authorizations/cookie/foo/bar") 66 | with requests_mock.Mocker() as m: 67 | m.get(url, text=json.dumps(mock_model)) 68 | user_model = auth.user_for_cookie('bar') 69 | assert user_model == mock_model 70 | # check cache 71 | user_model = auth.user_for_cookie('bar') 72 | assert user_model == mock_model 73 | 74 | with requests_mock.Mocker() as m: 75 | m.get(url, status_code=404) 76 | user_model = auth.user_for_cookie('bar', use_cache=False) 77 | assert user_model is None 78 | 79 | # invalidate cache with timer 80 | mock_model = { 81 | 'name': 'willow' 82 | } 83 | with monotonic_future, requests_mock.Mocker() as m: 84 | m.get(url, text=json.dumps(mock_model)) 85 | user_model = auth.user_for_cookie('bar') 86 | assert user_model == mock_model 87 | 88 | with requests_mock.Mocker() as m: 89 | m.get(url, status_code=500) 90 | with raises(HTTPError) as exc_info: 91 | user_model = auth.user_for_cookie('bar', use_cache=False) 92 | assert exc_info.value.status_code == 502 93 | 94 | with requests_mock.Mocker() as m: 95 | m.get(url, status_code=400) 96 | with raises(HTTPError) as exc_info: 97 | user_model = auth.user_for_cookie('bar', use_cache=False) 98 | assert exc_info.value.status_code == 500 99 | 100 | 101 | def test_hub_authenticated(request): 102 | auth = HubAuth(cookie_name='jubal') 103 | mock_model = { 104 | 'name': 'jubalearly' 105 | } 106 | cookie_url = url_path_join(auth.api_url, "authorizations/cookie", auth.cookie_name) 107 | good_url = url_path_join(cookie_url, "early") 108 | bad_url = url_path_join(cookie_url, "late") 109 | 110 | class TestHandler(HubAuthenticated, RequestHandler): 111 | hub_auth = auth 112 | @authenticated 113 | def get(self): 114 | self.finish(self.get_current_user()) 115 | 116 | # start hub-authenticated service in a thread: 117 | port = 50505 118 | q = Queue() 119 | def run(): 120 | app = Application([ 121 | ('/*', TestHandler), 122 | ], login_url=auth.login_url) 123 | 124 | http_server = HTTPServer(app) 125 | http_server.listen(port) 126 | loop = IOLoop.current() 127 | loop.add_callback(lambda : q.put(loop)) 128 | loop.start() 129 | 130 | t = Thread(target=run) 131 | t.start() 132 | 133 | def finish_thread(): 134 | loop.stop() 135 | t.join() 136 | request.addfinalizer(finish_thread) 137 | 138 | # wait for thread to start 139 | loop = q.get(timeout=10) 140 | 141 | with requests_mock.Mocker(real_http=True) as m: 142 | # no cookie 143 | r = requests.get('http://127.0.0.1:%i' % port, 144 | allow_redirects=False, 145 | ) 146 | r.raise_for_status() 147 | assert r.status_code == 302 148 | assert auth.login_url in r.headers['Location'] 149 | 150 | # wrong cookie 151 | m.get(bad_url, status_code=404) 152 | r = requests.get('http://127.0.0.1:%i' % port, 153 | cookies={'jubal': 'late'}, 154 | allow_redirects=False, 155 | ) 156 | r.raise_for_status() 157 | assert r.status_code == 302 158 | assert auth.login_url in r.headers['Location'] 159 | 160 | # upstream 403 161 | m.get(bad_url, status_code=403) 162 | r = requests.get('http://127.0.0.1:%i' % port, 163 | cookies={'jubal': 'late'}, 164 | allow_redirects=False, 165 | ) 166 | assert r.status_code == 500 167 | 168 | m.get(good_url, text=json.dumps(mock_model)) 169 | 170 | # no whitelist 171 | r = requests.get('http://127.0.0.1:%i' % port, 172 | cookies={'jubal': 'early'}, 173 | allow_redirects=False, 174 | ) 175 | r.raise_for_status() 176 | assert r.status_code == 200 177 | 178 | # pass whitelist 179 | TestHandler.hub_users = {'jubalearly'} 180 | r = requests.get('http://127.0.0.1:%i' % port, 181 | cookies={'jubal': 'early'}, 182 | allow_redirects=False, 183 | ) 184 | r.raise_for_status() 185 | assert r.status_code == 200 186 | 187 | # no pass whitelist 188 | TestHandler.hub_users = {'kaylee'} 189 | r = requests.get('http://127.0.0.1:%i' % port, 190 | cookies={'jubal': 'early'}, 191 | allow_redirects=False, 192 | ) 193 | r.raise_for_status() 194 | assert r.status_code == 302 195 | assert auth.login_url in r.headers['Location'] 196 | 197 | 198 | def test_service_cookie_auth(app, mockservice_url): 199 | cookies = app.login_user('badger') 200 | r = requests.get(public_url(app, mockservice_url) + '/whoami/', cookies=cookies) 201 | r.raise_for_status() 202 | print(r.text) 203 | reply = r.json() 204 | sub_reply = { key: reply.get(key, 'missing') for key in ['name', 'admin']} 205 | assert sub_reply == { 206 | 'name': 'badger', 207 | 'admin': False, 208 | } 209 | 210 | -------------------------------------------------------------------------------- /jupyterhub/tests/test_singleuser.py: -------------------------------------------------------------------------------- 1 | """Tests for jupyterhub.singleuser""" 2 | 3 | from subprocess import check_output 4 | import sys 5 | 6 | import requests 7 | 8 | import jupyterhub 9 | from .mocking import TestSingleUserSpawner, public_url 10 | from ..utils import url_path_join 11 | 12 | 13 | def test_singleuser_auth(app, io_loop): 14 | # use TestSingleUserSpawner to launch a single-user app in a thread 15 | app.spawner_class = TestSingleUserSpawner 16 | app.tornado_settings['spawner_class'] = TestSingleUserSpawner 17 | 18 | # login, start the server 19 | cookies = app.login_user('nandy') 20 | user = app.users['nandy'] 21 | if not user.running: 22 | io_loop.run_sync(user.spawn) 23 | url = public_url(app, user) 24 | 25 | # no cookies, redirects to login page 26 | r = requests.get(url) 27 | r.raise_for_status() 28 | assert '/hub/login' in r.url 29 | 30 | # with cookies, login successful 31 | r = requests.get(url, cookies=cookies) 32 | r.raise_for_status() 33 | assert r.url.rstrip('/').endswith('/user/nandy/tree') 34 | assert r.status_code == 200 35 | 36 | # logout 37 | r = requests.get(url_path_join(url, 'logout'), cookies=cookies) 38 | assert len(r.cookies) == 0 39 | 40 | 41 | def test_disable_user_config(app, io_loop): 42 | # use TestSingleUserSpawner to launch a single-user app in a thread 43 | app.spawner_class = TestSingleUserSpawner 44 | app.tornado_settings['spawner_class'] = TestSingleUserSpawner 45 | # login, start the server 46 | cookies = app.login_user('nandy') 47 | user = app.users['nandy'] 48 | # stop spawner, if running: 49 | if user.running: 50 | print("stopping") 51 | io_loop.run_sync(user.stop) 52 | # start with new config: 53 | user.spawner.debug = True 54 | user.spawner.disable_user_config = True 55 | io_loop.run_sync(user.spawn) 56 | io_loop.run_sync(lambda : app.proxy.add_user(user)) 57 | 58 | url = public_url(app, user) 59 | 60 | # with cookies, login successful 61 | r = requests.get(url, cookies=cookies) 62 | r.raise_for_status() 63 | assert r.url.rstrip('/').endswith('/user/nandy/tree') 64 | assert r.status_code == 200 65 | 66 | 67 | def test_help_output(): 68 | out = check_output([sys.executable, '-m', 'jupyterhub.singleuser', '--help-all']).decode('utf8', 'replace') 69 | assert 'JupyterHub' in out 70 | 71 | def test_version(): 72 | out = check_output([sys.executable, '-m', 'jupyterhub.singleuser', '--version']).decode('utf8', 'replace') 73 | assert jupyterhub.__version__ in out 74 | 75 | -------------------------------------------------------------------------------- /jupyterhub/tests/test_spawner.py: -------------------------------------------------------------------------------- 1 | """Tests for process spawning""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | 6 | import logging 7 | import os 8 | import signal 9 | import sys 10 | import tempfile 11 | import time 12 | from unittest import mock 13 | 14 | from tornado import gen 15 | 16 | from .. import spawner as spawnermod 17 | from ..spawner import LocalProcessSpawner 18 | from .. import orm 19 | 20 | _echo_sleep = """ 21 | import sys, time 22 | print(sys.argv) 23 | time.sleep(30) 24 | """ 25 | 26 | _uninterruptible = """ 27 | import time 28 | while True: 29 | try: 30 | time.sleep(10) 31 | except KeyboardInterrupt: 32 | print("interrupted") 33 | """ 34 | 35 | 36 | def setup(): 37 | logging.basicConfig(level=logging.DEBUG) 38 | 39 | 40 | def new_spawner(db, **kwargs): 41 | kwargs.setdefault('cmd', [sys.executable, '-c', _echo_sleep]) 42 | kwargs.setdefault('user', db.query(orm.User).first()) 43 | kwargs.setdefault('hub', db.query(orm.Hub).first()) 44 | kwargs.setdefault('notebook_dir', os.getcwd()) 45 | kwargs.setdefault('default_url', '/user/{username}/lab') 46 | kwargs.setdefault('INTERRUPT_TIMEOUT', 1) 47 | kwargs.setdefault('TERM_TIMEOUT', 1) 48 | kwargs.setdefault('KILL_TIMEOUT', 1) 49 | kwargs.setdefault('poll_interval', 1) 50 | return LocalProcessSpawner(db=db, **kwargs) 51 | 52 | 53 | def test_spawner(db, io_loop): 54 | spawner = new_spawner(db) 55 | ip, port = io_loop.run_sync(spawner.start) 56 | assert ip == '127.0.0.1' 57 | assert isinstance(port, int) 58 | assert port > 0 59 | spawner.user.server.ip = ip 60 | spawner.user.server.port = port 61 | db.commit() 62 | 63 | 64 | # wait for the process to get to the while True: loop 65 | time.sleep(1) 66 | 67 | status = io_loop.run_sync(spawner.poll) 68 | assert status is None 69 | io_loop.run_sync(spawner.stop) 70 | status = io_loop.run_sync(spawner.poll) 71 | assert status == 1 72 | 73 | def test_single_user_spawner(db, io_loop): 74 | spawner = new_spawner(db, cmd=['jupyterhub-singleuser']) 75 | spawner.api_token = 'secret' 76 | ip, port = io_loop.run_sync(spawner.start) 77 | assert ip == '127.0.0.1' 78 | assert isinstance(port, int) 79 | assert port > 0 80 | spawner.user.server.ip = ip 81 | spawner.user.server.port = port 82 | db.commit() 83 | # wait for http server to come up, 84 | # checking for early termination every 1s 85 | def wait(): 86 | return spawner.user.server.wait_up(timeout=1, http=True) 87 | for i in range(30): 88 | status = io_loop.run_sync(spawner.poll) 89 | assert status is None 90 | try: 91 | io_loop.run_sync(wait) 92 | except TimeoutError: 93 | continue 94 | else: 95 | break 96 | io_loop.run_sync(wait) 97 | status = io_loop.run_sync(spawner.poll) 98 | assert status == None 99 | io_loop.run_sync(spawner.stop) 100 | status = io_loop.run_sync(spawner.poll) 101 | assert status == 0 102 | 103 | 104 | def test_stop_spawner_sigint_fails(db, io_loop): 105 | spawner = new_spawner(db, cmd=[sys.executable, '-c', _uninterruptible]) 106 | io_loop.run_sync(spawner.start) 107 | 108 | # wait for the process to get to the while True: loop 109 | time.sleep(1) 110 | 111 | status = io_loop.run_sync(spawner.poll) 112 | assert status is None 113 | 114 | io_loop.run_sync(spawner.stop) 115 | status = io_loop.run_sync(spawner.poll) 116 | assert status == -signal.SIGTERM 117 | 118 | 119 | def test_stop_spawner_stop_now(db, io_loop): 120 | spawner = new_spawner(db) 121 | io_loop.run_sync(spawner.start) 122 | 123 | # wait for the process to get to the while True: loop 124 | time.sleep(1) 125 | 126 | status = io_loop.run_sync(spawner.poll) 127 | assert status is None 128 | 129 | io_loop.run_sync(lambda : spawner.stop(now=True)) 130 | status = io_loop.run_sync(spawner.poll) 131 | assert status == -signal.SIGTERM 132 | 133 | 134 | def test_spawner_poll(db, io_loop): 135 | first_spawner = new_spawner(db) 136 | user = first_spawner.user 137 | io_loop.run_sync(first_spawner.start) 138 | proc = first_spawner.proc 139 | status = io_loop.run_sync(first_spawner.poll) 140 | assert status is None 141 | user.state = first_spawner.get_state() 142 | assert 'pid' in user.state 143 | 144 | # create a new Spawner, loading from state of previous 145 | spawner = new_spawner(db, user=first_spawner.user) 146 | spawner.start_polling() 147 | 148 | # wait for the process to get to the while True: loop 149 | io_loop.run_sync(lambda : gen.sleep(1)) 150 | status = io_loop.run_sync(spawner.poll) 151 | assert status is None 152 | 153 | # kill the process 154 | proc.terminate() 155 | for i in range(10): 156 | if proc.poll() is None: 157 | time.sleep(1) 158 | else: 159 | break 160 | assert proc.poll() is not None 161 | 162 | io_loop.run_sync(lambda : gen.sleep(2)) 163 | status = io_loop.run_sync(spawner.poll) 164 | assert status is not None 165 | 166 | 167 | def test_setcwd(): 168 | cwd = os.getcwd() 169 | with tempfile.TemporaryDirectory() as td: 170 | td = os.path.realpath(os.path.abspath(td)) 171 | spawnermod._try_setcwd(td) 172 | assert os.path.samefile(os.getcwd(), td) 173 | os.chdir(cwd) 174 | chdir = os.chdir 175 | temp_root = os.path.realpath(os.path.abspath(tempfile.gettempdir())) 176 | def raiser(path): 177 | path = os.path.realpath(os.path.abspath(path)) 178 | if not path.startswith(temp_root): 179 | raise OSError(path) 180 | chdir(path) 181 | with mock.patch('os.chdir', raiser): 182 | spawnermod._try_setcwd(cwd) 183 | assert os.getcwd().startswith(temp_root) 184 | os.chdir(cwd) 185 | 186 | 187 | def test_string_formatting(db): 188 | s = new_spawner(db, notebook_dir='user/%U/', default_url='/base/{username}') 189 | name = s.user.name 190 | assert s.notebook_dir == 'user/{username}/' 191 | assert s.default_url == '/base/{username}' 192 | assert s.format_string(s.notebook_dir) == 'user/%s/' % name 193 | assert s.format_string(s.default_url) == '/base/%s' % name 194 | 195 | -------------------------------------------------------------------------------- /jupyterhub/tests/test_traitlets.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from traitlets import HasTraits, TraitError 3 | 4 | from jupyterhub.traitlets import URLPrefix, Command, ByteSpecification 5 | 6 | 7 | def test_url_prefix(): 8 | class C(HasTraits): 9 | url = URLPrefix() 10 | c = C() 11 | c.url = '/a/b/c/' 12 | assert c.url == '/a/b/c/' 13 | c.url = '/a/b' 14 | assert c.url == '/a/b/' 15 | c.url = 'a/b/c/d' 16 | assert c.url == '/a/b/c/d/' 17 | 18 | 19 | def test_command(): 20 | class C(HasTraits): 21 | cmd = Command('default command') 22 | cmd2 = Command(['default_cmd']) 23 | c = C() 24 | assert c.cmd == ['default command'] 25 | assert c.cmd2 == ['default_cmd'] 26 | c.cmd = 'foo bar' 27 | assert c.cmd == ['foo bar'] 28 | 29 | 30 | def test_memoryspec(): 31 | class C(HasTraits): 32 | mem = ByteSpecification() 33 | 34 | c = C() 35 | 36 | c.mem = 1024 37 | assert c.mem == 1024 38 | 39 | c.mem = '1024K' 40 | assert c.mem == 1024 * 1024 41 | 42 | c.mem = '1024M' 43 | assert c.mem == 1024 * 1024 * 1024 44 | 45 | c.mem = '1024G' 46 | assert c.mem == 1024 * 1024 * 1024 * 1024 47 | 48 | c.mem = '1024T' 49 | assert c.mem == 1024 * 1024 * 1024 * 1024 * 1024 50 | 51 | with pytest.raises(TraitError): 52 | c.mem = '1024Gi' 53 | -------------------------------------------------------------------------------- /jupyterhub/traitlets.py: -------------------------------------------------------------------------------- 1 | """ 2 | Traitlets that are used in JupyterHub 3 | """ 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | from traitlets import List, Unicode, Integer, TraitError 8 | 9 | 10 | class URLPrefix(Unicode): 11 | def validate(self, obj, value): 12 | u = super().validate(obj, value) 13 | if not u.startswith('/'): 14 | u = '/' + u 15 | if not u.endswith('/'): 16 | u = u + '/' 17 | return u 18 | 19 | class Command(List): 20 | """Traitlet for a command that should be a list of strings, 21 | but allows it to be specified as a single string. 22 | """ 23 | def __init__(self, default_value=None, **kwargs): 24 | kwargs.setdefault('minlen', 1) 25 | if isinstance(default_value, str): 26 | default_value = [default_value] 27 | super().__init__(Unicode(), default_value, **kwargs) 28 | 29 | def validate(self, obj, value): 30 | if isinstance(value, str): 31 | value = [value] 32 | return super().validate(obj, value) 33 | 34 | 35 | class ByteSpecification(Integer): 36 | """ 37 | Allow easily specifying bytes in units of 1024 with suffixes 38 | 39 | Suffixes allowed are: 40 | - K -> Kilobyte 41 | - M -> Megabyte 42 | - G -> Gigabyte 43 | - T -> Terabyte 44 | """ 45 | 46 | UNIT_SUFFIXES = { 47 | 'K': 1024, 48 | 'M': 1024 * 1024, 49 | 'G': 1024 * 1024 * 1024, 50 | 'T': 1024 * 1024 * 1024 * 1024 51 | } 52 | 53 | # Default to allowing None as a value 54 | allow_none = True 55 | 56 | def validate(self, obj, value): 57 | """ 58 | Validate that the passed in value is a valid memory specification 59 | 60 | It could either be a pure int, when it is taken as a byte value. 61 | If it has one of the suffixes, it is converted into the appropriate 62 | pure byte value. 63 | """ 64 | if isinstance(value, int): 65 | return value 66 | num = value[:-1] 67 | suffix = value[-1] 68 | if not num.isdigit() and suffix not in ByteSpecification.UNIT_SUFFIXES: 69 | raise TraitError('{val} is not a valid memory specification. Must be an int or a string with suffix K, M, G, T'.format(val=value)) 70 | else: 71 | return int(num) * ByteSpecification.UNIT_SUFFIXES[suffix] 72 | -------------------------------------------------------------------------------- /jupyterhub/utils.py: -------------------------------------------------------------------------------- 1 | """Miscellaneous utilities""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | 6 | from binascii import b2a_hex 7 | import errno 8 | import hashlib 9 | from hmac import compare_digest 10 | import os 11 | import socket 12 | from threading import Thread 13 | import uuid 14 | import warnings 15 | 16 | from tornado import web, gen, ioloop 17 | from tornado.httpclient import AsyncHTTPClient, HTTPError 18 | from tornado.log import app_log 19 | 20 | 21 | def random_port(): 22 | """get a single random port""" 23 | sock = socket.socket() 24 | sock.bind(('', 0)) 25 | port = sock.getsockname()[1] 26 | sock.close() 27 | return port 28 | 29 | # ISO8601 for strptime with/without milliseconds 30 | ISO8601_ms = '%Y-%m-%dT%H:%M:%S.%fZ' 31 | ISO8601_s = '%Y-%m-%dT%H:%M:%SZ' 32 | 33 | def can_connect(ip, port): 34 | """Check if we can connect to an ip:port 35 | 36 | return True if we can connect, False otherwise. 37 | """ 38 | try: 39 | socket.create_connection((ip, port)) 40 | except socket.error as e: 41 | if e.errno not in {errno.ECONNREFUSED, errno.ETIMEDOUT}: 42 | app_log.error("Unexpected error connecting to %s:%i %s", 43 | ip, port, e 44 | ) 45 | return False 46 | else: 47 | return True 48 | 49 | @gen.coroutine 50 | def wait_for_server(ip, port, timeout=10): 51 | """wait for any server to show up at ip:port""" 52 | loop = ioloop.IOLoop.current() 53 | tic = loop.time() 54 | while loop.time() - tic < timeout: 55 | if can_connect(ip, port): 56 | return 57 | else: 58 | yield gen.sleep(0.1) 59 | raise TimeoutError("Server at {ip}:{port} didn't respond in {timeout} seconds".format( 60 | **locals() 61 | )) 62 | 63 | @gen.coroutine 64 | def wait_for_http_server(url, timeout=10): 65 | """Wait for an HTTP Server to respond at url 66 | 67 | Any non-5XX response code will do, even 404. 68 | """ 69 | loop = ioloop.IOLoop.current() 70 | tic = loop.time() 71 | client = AsyncHTTPClient() 72 | while loop.time() - tic < timeout: 73 | try: 74 | r = yield client.fetch(url, follow_redirects=False) 75 | except HTTPError as e: 76 | if e.code >= 500: 77 | # failed to respond properly, wait and try again 78 | if e.code != 599: 79 | # we expect 599 for no connection, 80 | # but 502 or other proxy error is conceivable 81 | app_log.warning("Server at %s responded with error: %s", url, e.code) 82 | yield gen.sleep(0.1) 83 | else: 84 | app_log.debug("Server at %s responded with %s", url, e.code) 85 | return 86 | except (OSError, socket.error) as e: 87 | if e.errno not in {errno.ECONNABORTED, errno.ECONNREFUSED, errno.ECONNRESET}: 88 | app_log.warning("Failed to connect to %s (%s)", url, e) 89 | yield gen.sleep(0.1) 90 | else: 91 | return 92 | 93 | raise TimeoutError("Server at {url} didn't respond in {timeout} seconds".format( 94 | **locals() 95 | )) 96 | 97 | 98 | # Decorators for authenticated Handlers 99 | 100 | def auth_decorator(check_auth): 101 | """Make an authentication decorator 102 | 103 | I heard you like decorators, so I put a decorator 104 | in your decorator, so you can decorate while you decorate. 105 | """ 106 | def decorator(method): 107 | def decorated(self, *args, **kwargs): 108 | check_auth(self) 109 | return method(self, *args, **kwargs) 110 | decorated.__name__ = method.__name__ 111 | decorated.__doc__ = method.__doc__ 112 | return decorated 113 | 114 | decorator.__name__ = check_auth.__name__ 115 | decorator.__doc__ = check_auth.__doc__ 116 | return decorator 117 | 118 | @auth_decorator 119 | def token_authenticated(self): 120 | """decorator for a method authenticated only by the Authorization token header 121 | 122 | (no cookies) 123 | """ 124 | if self.get_current_user_token() is None: 125 | raise web.HTTPError(403) 126 | 127 | @auth_decorator 128 | def authenticated_403(self): 129 | """like web.authenticated, but raise 403 instead of redirect to login""" 130 | if self.get_current_user() is None: 131 | raise web.HTTPError(403) 132 | 133 | @auth_decorator 134 | def admin_only(self): 135 | """decorator for restricting access to admin users""" 136 | user = self.get_current_user() 137 | if user is None or not user.admin: 138 | raise web.HTTPError(403) 139 | 140 | 141 | # Token utilities 142 | 143 | def new_token(*args, **kwargs): 144 | """generator for new random tokens 145 | 146 | For now, just UUIDs. 147 | """ 148 | return uuid.uuid4().hex 149 | 150 | 151 | def hash_token(token, salt=8, rounds=16384, algorithm='sha512'): 152 | """hash a token, and return it as `algorithm:salt:hash` 153 | 154 | If `salt` is an integer, a random salt of that many bytes will be used. 155 | """ 156 | h = hashlib.new(algorithm) 157 | if isinstance(salt, int): 158 | salt = b2a_hex(os.urandom(salt)) 159 | if isinstance(salt, bytes): 160 | bsalt = salt 161 | salt = salt.decode('utf8') 162 | else: 163 | bsalt = salt.encode('utf8') 164 | btoken = token.encode('utf8', 'replace') 165 | h.update(bsalt) 166 | for i in range(rounds): 167 | h.update(btoken) 168 | digest = h.hexdigest() 169 | 170 | return "{algorithm}:{rounds}:{salt}:{digest}".format(**locals()) 171 | 172 | 173 | def compare_token(compare, token): 174 | """compare a token with a hashed token 175 | 176 | uses the same algorithm and salt of the hashed token for comparison 177 | """ 178 | algorithm, srounds, salt, _ = compare.split(':') 179 | hashed = hash_token(token, salt=salt, rounds=int(srounds), algorithm=algorithm).encode('utf8') 180 | compare = compare.encode('utf8') 181 | if compare_digest(compare, hashed): 182 | return True 183 | return False 184 | 185 | 186 | def url_path_join(*pieces): 187 | """Join components of url into a relative url 188 | 189 | Use to prevent double slash when joining subpath. This will leave the 190 | initial and final / in place 191 | 192 | Copied from notebook.utils.url_path_join 193 | """ 194 | initial = pieces[0].startswith('/') 195 | final = pieces[-1].endswith('/') 196 | stripped = [ s.strip('/') for s in pieces ] 197 | result = '/'.join(s for s in stripped if s) 198 | 199 | if initial: 200 | result = '/' + result 201 | if final: 202 | result = result + '/' 203 | if result == '//': 204 | result = '/' 205 | 206 | return result 207 | 208 | -------------------------------------------------------------------------------- /jupyterhub/version.py: -------------------------------------------------------------------------------- 1 | """jupyterhub version info""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | 6 | version_info = ( 7 | 0, 8 | 8, 9 | 0, 10 | 'dev', 11 | ) 12 | 13 | __version__ = '.'.join(map(str, version_info)) 14 | -------------------------------------------------------------------------------- /onbuild/Dockerfile: -------------------------------------------------------------------------------- 1 | # JupyterHub Dockerfile that loads your jupyterhub_config.py 2 | # 3 | # Adds ONBUILD step to jupyter/jupyterhub to load your juptyerhub_config.py into the image 4 | # 5 | # Derivative images must have jupyterhub_config.py next to the Dockerfile. 6 | 7 | FROM jupyterhub/jupyterhub 8 | 9 | ONBUILD ADD jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py 10 | 11 | CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"] 12 | -------------------------------------------------------------------------------- /onbuild/README.md: -------------------------------------------------------------------------------- 1 | # JupyterHub onbuild image 2 | 3 | If you base a Dockerfile on this image: 4 | 5 | FROM juptyerhub/jupyterhub-onbuild:0.6 6 | ... 7 | 8 | then your `jupyterhub_config.py` adjacent to your Dockerfile will be loaded into the image and used by JupyterHub. 9 | 10 | This is how the `jupyter/jupyterhub` docker image behaved prior to 0.6. 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyterhub-deps", 3 | "version": "0.0.0", 4 | "description": "JupyterHub nodejs dependencies", 5 | "author": "Jupyter Developers", 6 | "license": "BSD", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/jupyter/jupyterhub.git" 10 | }, 11 | "devDependencies": { 12 | "bower": "*", 13 | "less": "^2.7.1", 14 | "less-plugin-clean-css": "^1.5.1", 15 | "clean-css": "^3.4.13" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | name: jupyterhub 2 | type: sphinx 3 | conda: 4 | file: docs/environment.yml 5 | python: 6 | version: 3 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic 2 | traitlets>=4.1 3 | tornado>=4.1 4 | jinja2 5 | pamela 6 | sqlalchemy>=1.0 7 | requests 8 | -------------------------------------------------------------------------------- /scripts/jupyterhub: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from jupyterhub.app import main 4 | main() 5 | -------------------------------------------------------------------------------- /scripts/jupyterhub-singleuser: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from jupyterhub.singleuser import main 4 | 5 | if __name__ == '__main__': 6 | main() 7 | -------------------------------------------------------------------------------- /setupegg.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Wrapper to run setup.py using setuptools.""" 3 | 4 | # Import setuptools and call the actual setup 5 | import setuptools 6 | with open('setup.py', 'rb') as f: 7 | exec(compile(f.read(), 'setup.py', 'exec')) 8 | -------------------------------------------------------------------------------- /share/jupyter/hub/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantopian/jupyterhub/a8bd7a697e326274187753fabbb8b30fa18bd79b/share/jupyter/hub/static/favicon.ico -------------------------------------------------------------------------------- /share/jupyter/hub/static/images/jupyter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantopian/jupyterhub/a8bd7a697e326274187753fabbb8b30fa18bd79b/share/jupyter/hub/static/images/jupyter.png -------------------------------------------------------------------------------- /share/jupyter/hub/static/images/jupyterhub-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantopian/jupyterhub/a8bd7a697e326274187753fabbb8b30fa18bd79b/share/jupyter/hub/static/images/jupyterhub-80.png -------------------------------------------------------------------------------- /share/jupyter/hub/static/js/home.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | require(["jquery", "jhapi"], function ($, JHAPI) { 5 | "use strict"; 6 | 7 | var base_url = window.jhdata.base_url; 8 | var user = window.jhdata.user; 9 | var api = new JHAPI(base_url); 10 | 11 | $("#stop").click(function () { 12 | api.stop_server(user, { 13 | success: function () { 14 | $("#stop").hide(); 15 | } 16 | }); 17 | }); 18 | 19 | }); 20 | -------------------------------------------------------------------------------- /share/jupyter/hub/static/js/jhapi.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | define(['jquery', 'utils'], function ($, utils) { 5 | "use strict"; 6 | 7 | var JHAPI = function (base_url) { 8 | this.base_url = base_url; 9 | }; 10 | 11 | var default_options = { 12 | type: 'GET', 13 | contentType: "application/json", 14 | cache: false, 15 | dataType : "json", 16 | processData: false, 17 | success: null, 18 | error: utils.ajax_error_dialog, 19 | }; 20 | 21 | var update = function (d1, d2) { 22 | $.map(d2, function (i, key) { 23 | d1[key] = d2[key]; 24 | }); 25 | return d1; 26 | }; 27 | 28 | var ajax_defaults = function (options) { 29 | var d = {}; 30 | update(d, default_options); 31 | update(d, options); 32 | return d; 33 | }; 34 | 35 | JHAPI.prototype.api_request = function (path, options) { 36 | options = options || {}; 37 | options = ajax_defaults(options || {}); 38 | var url = utils.url_path_join( 39 | this.base_url, 40 | 'api', 41 | utils.encode_uri_components(path) 42 | ); 43 | $.ajax(url, options); 44 | }; 45 | 46 | JHAPI.prototype.start_server = function (user, options) { 47 | options = options || {}; 48 | options = update(options, {type: 'POST', dataType: null}); 49 | this.api_request( 50 | utils.url_path_join('users', user, 'server'), 51 | options 52 | ); 53 | }; 54 | 55 | JHAPI.prototype.stop_server = function (user, options) { 56 | options = options || {}; 57 | options = update(options, {type: 'DELETE', dataType: null}); 58 | this.api_request( 59 | utils.url_path_join('users', user, 'server'), 60 | options 61 | ); 62 | }; 63 | 64 | JHAPI.prototype.list_users = function (options) { 65 | this.api_request('users', options); 66 | }; 67 | 68 | JHAPI.prototype.get_user = function (user, options) { 69 | this.api_request( 70 | utils.url_path_join('users', user), 71 | options 72 | ); 73 | }; 74 | 75 | JHAPI.prototype.add_users = function (usernames, userinfo, options) { 76 | options = options || {}; 77 | var data = update(userinfo, {usernames: usernames}); 78 | options = update(options, { 79 | type: 'POST', 80 | dataType: null, 81 | data: JSON.stringify(data) 82 | }); 83 | 84 | this.api_request('users', options); 85 | }; 86 | 87 | JHAPI.prototype.edit_user = function (user, userinfo, options) { 88 | options = options || {}; 89 | options = update(options, { 90 | type: 'PATCH', 91 | dataType: null, 92 | data: JSON.stringify(userinfo) 93 | }); 94 | 95 | this.api_request( 96 | utils.url_path_join('users', user), 97 | options 98 | ); 99 | }; 100 | 101 | JHAPI.prototype.admin_access = function (user, options) { 102 | options = options || {}; 103 | options = update(options, { 104 | type: 'POST', 105 | dataType: null, 106 | }); 107 | 108 | this.api_request( 109 | utils.url_path_join('users', user, 'admin-access'), 110 | options 111 | ); 112 | }; 113 | 114 | JHAPI.prototype.delete_user = function (user, options) { 115 | options = options || {}; 116 | options = update(options, {type: 'DELETE', dataType: null}); 117 | this.api_request( 118 | utils.url_path_join('users', user), 119 | options 120 | ); 121 | }; 122 | 123 | JHAPI.prototype.shutdown_hub = function (data, options) { 124 | options = options || {}; 125 | options = update(options, {type: 'POST'}); 126 | if (data) { 127 | options.data = JSON.stringify(data); 128 | } 129 | this.api_request('shutdown', options); 130 | }; 131 | 132 | return JHAPI; 133 | }); -------------------------------------------------------------------------------- /share/jupyter/hub/static/js/utils.js: -------------------------------------------------------------------------------- 1 | // Based on IPython's base.js.utils 2 | // Original Copyright (c) IPython Development Team. 3 | // Distributed under the terms of the Modified BSD License. 4 | 5 | // Modifications Copyright (c) Juptyer Development Team. 6 | // Distributed under the terms of the Modified BSD License. 7 | 8 | define(['jquery'], function($){ 9 | "use strict"; 10 | 11 | var url_path_join = function () { 12 | // join a sequence of url components with '/' 13 | var url = ''; 14 | for (var i = 0; i < arguments.length; i++) { 15 | if (arguments[i] === '') { 16 | continue; 17 | } 18 | if (url.length > 0 && url[url.length-1] != '/') { 19 | url = url + '/' + arguments[i]; 20 | } else { 21 | url = url + arguments[i]; 22 | } 23 | } 24 | url = url.replace(/\/\/+/, '/'); 25 | return url; 26 | }; 27 | 28 | var parse_url = function (url) { 29 | // an `a` element with an href allows attr-access to the parsed segments of a URL 30 | // a = parse_url("http://localhost:8888/path/name#hash") 31 | // a.protocol = "http:" 32 | // a.host = "localhost:8888" 33 | // a.hostname = "localhost" 34 | // a.port = 8888 35 | // a.pathname = "/path/name" 36 | // a.hash = "#hash" 37 | var a = document.createElement("a"); 38 | a.href = url; 39 | return a; 40 | }; 41 | 42 | var encode_uri_components = function (uri) { 43 | // encode just the components of a multi-segment uri, 44 | // leaving '/' separators 45 | return uri.split('/').map(encodeURIComponent).join('/'); 46 | }; 47 | 48 | var url_join_encode = function () { 49 | // join a sequence of url components with '/', 50 | // encoding each component with encodeURIComponent 51 | return encode_uri_components(url_path_join.apply(null, arguments)); 52 | }; 53 | 54 | 55 | var escape_html = function (text) { 56 | // escape text to HTML 57 | return $("
").text(text).html(); 58 | }; 59 | 60 | var get_body_data = function(key) { 61 | // get a url-encoded item from body.data and decode it 62 | // we should never have any encoded URLs anywhere else in code 63 | // until we are building an actual request 64 | return decodeURIComponent($('body').data(key)); 65 | }; 66 | 67 | 68 | // http://stackoverflow.com/questions/2400935/browser-detection-in-javascript 69 | var browser = (function() { 70 | if (typeof navigator === 'undefined') { 71 | // navigator undefined in node 72 | return 'None'; 73 | } 74 | var N= navigator.appName, ua= navigator.userAgent, tem; 75 | var M= ua.match(/(opera|chrome|safari|firefox|msie)\/?\s*(\.?\d+(\.\d+)*)/i); 76 | if (M && (tem= ua.match(/version\/([\.\d]+)/i)) !== null) M[2]= tem[1]; 77 | M= M? [M[1], M[2]]: [N, navigator.appVersion,'-?']; 78 | return M; 79 | })(); 80 | 81 | // http://stackoverflow.com/questions/11219582/how-to-detect-my-browser-version-and-operating-system-using-javascript 82 | var platform = (function () { 83 | if (typeof navigator === 'undefined') { 84 | // navigator undefined in node 85 | return 'None'; 86 | } 87 | var OSName="None"; 88 | if (navigator.appVersion.indexOf("Win")!=-1) OSName="Windows"; 89 | if (navigator.appVersion.indexOf("Mac")!=-1) OSName="MacOS"; 90 | if (navigator.appVersion.indexOf("X11")!=-1) OSName="UNIX"; 91 | if (navigator.appVersion.indexOf("Linux")!=-1) OSName="Linux"; 92 | return OSName; 93 | })(); 94 | 95 | var ajax_error_msg = function (jqXHR) { 96 | // Return a JSON error message if there is one, 97 | // otherwise the basic HTTP status text. 98 | if (jqXHR.responseJSON && jqXHR.responseJSON.message) { 99 | return jqXHR.responseJSON.message; 100 | } else { 101 | return jqXHR.statusText; 102 | } 103 | }; 104 | 105 | var log_ajax_error = function (jqXHR, status, error) { 106 | // log ajax failures with informative messages 107 | var msg = "API request failed (" + jqXHR.status + "): "; 108 | console.log(jqXHR); 109 | msg += ajax_error_msg(jqXHR); 110 | console.log(msg); 111 | return msg; 112 | }; 113 | 114 | var ajax_error_dialog = function (jqXHR, status, error) { 115 | console.log("ajax dialog", arguments); 116 | var msg = log_ajax_error(jqXHR, status, error); 117 | var dialog = $("#error-dialog"); 118 | dialog.find(".ajax-error").text(msg); 119 | dialog.modal(); 120 | }; 121 | 122 | var utils = { 123 | url_path_join : url_path_join, 124 | url_join_encode : url_join_encode, 125 | encode_uri_components : encode_uri_components, 126 | escape_html : escape_html, 127 | get_body_data : get_body_data, 128 | parse_url : parse_url, 129 | browser : browser, 130 | platform: platform, 131 | ajax_error_msg : ajax_error_msg, 132 | log_ajax_error : log_ajax_error, 133 | ajax_error_dialog : ajax_error_dialog, 134 | }; 135 | 136 | return utils; 137 | }); 138 | -------------------------------------------------------------------------------- /share/jupyter/hub/static/less/admin.less: -------------------------------------------------------------------------------- 1 | i.sort-icon { 2 | margin-left: 4px; 3 | } -------------------------------------------------------------------------------- /share/jupyter/hub/static/less/error.less: -------------------------------------------------------------------------------- 1 | div.error { 2 | margin: 2em; 3 | text-align: center; 4 | } 5 | 6 | div.ajax-error { 7 | padding: 1em; 8 | text-align: center; 9 | .alert-danger(); 10 | } 11 | 12 | div.error > h1 { 13 | font-size: 300%; 14 | line-height: normal; 15 | } 16 | 17 | div.error > p { 18 | font-size: 200%; 19 | line-height: normal; 20 | } 21 | 22 | -------------------------------------------------------------------------------- /share/jupyter/hub/static/less/login.less: -------------------------------------------------------------------------------- 1 | #login-main { 2 | display: table; 3 | height: 80vh; 4 | 5 | & #insecure-login-warning{ 6 | .bg-warning(); 7 | padding:10px; 8 | } 9 | 10 | .service-login { 11 | text-align: center; 12 | display: table-cell; 13 | vertical-align: middle; 14 | margin: auto auto 20% auto; 15 | } 16 | 17 | form { 18 | display: table-cell; 19 | vertical-align: middle; 20 | margin: auto auto 20% auto; 21 | width: 350px; 22 | font-size: large; 23 | } 24 | 25 | .input-group, input[type=text], button { 26 | width: 100%; 27 | } 28 | 29 | input[type=submit] { 30 | margin-top: 16px; 31 | } 32 | 33 | .form-control:focus, input[type=submit]:focus { 34 | box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px @jupyter-orange; 35 | border-color: @jupyter-orange; 36 | outline-color: @jupyter-orange; 37 | } 38 | 39 | .login_error { 40 | color: orangered; 41 | font-weight: bold; 42 | text-align: center; 43 | } 44 | 45 | .auth-form-header { 46 | padding: 10px 20px; 47 | color: #fff; 48 | background: @jupyter-orange; 49 | border-radius: @border-radius-large @border-radius-large 0 0; 50 | } 51 | 52 | .auth-form-body { 53 | padding: 20px; 54 | font-size: 14px; 55 | border: thin silver solid; 56 | border-top: none; 57 | border-radius: 0 0 @border-radius-large @border-radius-large; 58 | } 59 | } 60 | 61 | -------------------------------------------------------------------------------- /share/jupyter/hub/static/less/page.less: -------------------------------------------------------------------------------- 1 | .jpy-logo { 2 | height: 28px; 3 | margin-top: 6px; 4 | } 5 | 6 | #header { 7 | border-bottom: 1px solid #e7e7e7; 8 | height: 40px; 9 | } 10 | 11 | .hidden { 12 | display: none; 13 | } 14 | 15 | .dropdown.navbar-btn{ 16 | padding:0 5px 0 0; 17 | } 18 | 19 | #login_widget{ 20 | 21 | & .navbar-btn.btn-sm { 22 | margin-top: 5px; 23 | margin-bottom: 5px; 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /share/jupyter/hub/static/less/style.less: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Twitter Bootstrap 4 | * 5 | */ 6 | @import "../components/bootstrap/less/bootstrap.less"; 7 | @import "../components/bootstrap/less/responsive-utilities.less"; 8 | 9 | /*! 10 | * 11 | * Font Awesome 12 | * 13 | */ 14 | @import "../components/font-awesome/less/font-awesome.less"; 15 | @fa-font-path: "../components/font-awesome/fonts"; 16 | 17 | /*! 18 | * 19 | * Jupyter 20 | * 21 | */ 22 | 23 | @import "./variables.less"; 24 | @import "./page.less"; 25 | @import "./admin.less"; 26 | @import "./error.less"; 27 | @import "./login.less"; 28 | -------------------------------------------------------------------------------- /share/jupyter/hub/static/less/variables.less: -------------------------------------------------------------------------------- 1 | @border-radius-small: 2px; 2 | @border-radius-base: 2px; 3 | @border-radius-large: 3px; 4 | @navbar-height: 20px; 5 | 6 | @jupyter-orange: #F37524; 7 | @jupyter-red: #E34F21; 8 | 9 | .btn-jupyter { 10 | .button-variant(#fff; @jupyter-orange; @jupyter-red); 11 | } 12 | -------------------------------------------------------------------------------- /share/jupyter/hub/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "error.html" %} 2 | 3 | {% block error_detail %} 4 |

Jupyter has lots of moons, but this is not one...

5 | {% endblock %} 6 | 7 | -------------------------------------------------------------------------------- /share/jupyter/hub/templates/admin.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% macro th(label, key='', colspan=1) %} 4 | {{label}} 5 | {% if key %} 6 | 13 | 14 | {% endif %} 15 | 16 | {% endmacro %} 17 | 18 | {% block main %} 19 | 20 |
21 | 22 | 23 | 24 | {% block thead %} 25 | {{ th("User (%i)" % users|length, 'name') }} 26 | {{ th("Admin", 'admin') }} 27 | {{ th("Last Seen", 'last_activity') }} 28 | {{ th("Running (%i)" % running|length, 'running', colspan=2) }} 29 | {% endblock thead %} 30 | 31 | 32 | 33 | 34 | 39 | 40 | {% for u in users %} 41 | 42 | {% block user_row scoped %} 43 | 44 | 45 | 46 | 50 | 55 | 58 | 63 | {% endblock user_row %} 64 | 65 | {% endfor %} 66 | 67 |
35 | Add Users 36 | Stop All 37 | Shutdown Hub 38 |
{{u.name}}{% if u.admin %}admin{% endif %}{{u.last_activity.isoformat() + 'Z'}} 47 | stop server 48 | start server 49 | 51 | {% if admin_access %} 52 | access server 53 | {% endif %} 54 | 56 | edit 57 | 59 | {% if u.name != user.name %} 60 | delete 61 | {% endif %} 62 |
68 |
69 | 70 | {% call modal('Delete User', btn_class='btn-danger delete-button') %} 71 | Are you sure you want to delete user USER? 72 | This operation cannot be undone. 73 | {% endcall %} 74 | 75 | {% call modal('Stop All Servers', btn_label='Stop All', btn_class='btn-danger stop-all-button') %} 76 | Are you sure you want to stop all your users' servers? Kernels will be shutdown and unsaved data may be lost. 77 | {% endcall %} 78 | 79 | {% call modal('Shutdown Hub', btn_label='Shutdown', btn_class='btn-danger shutdown-button') %} 80 | Are you sure you want to shutdown the Hub? 81 | You can choose to leave the proxy and/or single-user servers running by unchecking the boxes below: 82 |
83 | 86 |
87 |
88 | 91 |
92 | {% endcall %} 93 | 94 | {% macro user_modal(name, multi=False) %} 95 | {% call modal(name, btn_class='btn-primary save-button') %} 96 |
97 | <{%- if multi -%} 98 | textarea 99 | {%- else -%} 100 | input type="text" 101 | {%- endif %} 102 | class="form-control username-input" 103 | placeholder="{%- if multi -%} usernames separated by lines{%- else -%} username {%-endif-%}"> 104 | {%- if multi -%}{%- endif -%} 105 |
106 |
107 | 110 |
111 | {% endcall %} 112 | {% endmacro %} 113 | 114 | {{ user_modal('Edit User') }} 115 | 116 | {{ user_modal('Add Users', multi=True) }} 117 | 118 | {% endblock %} 119 | 120 | {% block script %} 121 | 124 | {% endblock %} 125 | -------------------------------------------------------------------------------- /share/jupyter/hub/templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block login_widget %} 4 | {% endblock %} 5 | 6 | {% block main %} 7 | 8 |
9 | {% block h1_error %} 10 |

11 | {{status_code}} : {{status_message}} 12 |

13 | {% endblock h1_error %} 14 | {% block error_detail %} 15 | {% if message %} 16 |

17 | {{message}} 18 |

19 | {% endif %} 20 | {% if message_html %} 21 |

22 | {{message_html | safe}} 23 |

24 | {% endif %} 25 | {% endblock error_detail %} 26 |
27 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /share/jupyter/hub/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block main %} 4 | 5 |
6 |
7 |
8 | {% if user.running %} 9 | Stop My Server 10 | {% endif %} 11 | 18 | {% if not user.running %} 19 | Start 20 | {% endif %} 21 | My Server 22 | 23 | {% if user.admin %} 24 | Admin 25 | {% endif %} 26 |
27 |
28 |
29 | 30 | {% endblock %} 31 | 32 | {% block script %} 33 | 36 | {% endblock %} -------------------------------------------------------------------------------- /share/jupyter/hub/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block login_widget %} 4 | {% endblock %} 5 | 6 | {% block main %} 7 | 8 | {% block login %} 9 |
10 | {% if custom_html %} 11 | {{ custom_html }} 12 | {% elif login_service %} 13 | 18 | {% else %} 19 |
20 |
21 | Sign in 22 |
23 |
24 | 25 | 29 | 30 | {% if login_error %} 31 | 34 | {% endif %} 35 | 36 | 47 | 48 | 55 | 56 | 63 |
64 |
65 | {% endif %} 66 |
67 | {% endblock login %} 68 | 69 | {% endblock %} 70 | 71 | {% block script %} 72 | {{super()}} 73 | 74 | 81 | 82 | {% endblock %} 83 | -------------------------------------------------------------------------------- /share/jupyter/hub/templates/page.html: -------------------------------------------------------------------------------- 1 | {% macro modal(title, btn_label=None, btn_class="btn-primary") %} 2 | {% set key = title.replace(' ', '-').lower() %} 3 | {% set btn_label = btn_label or title %} 4 | 21 | {% endmacro %} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {% block title %}Jupyter Hub{% endblock %} 31 | 32 | 33 | 34 | {% block stylesheet %} 35 | 36 | {% endblock %} 37 | 38 | 58 | 59 | 68 | 69 | {% block meta %} 70 | {% endblock %} 71 | 72 | 73 | 74 | 75 | 76 | 82 | 83 | 103 | 104 | {% block main %} 105 | {% endblock %} 106 | 107 | {% call modal('Error', btn_label='OK') %} 108 |
109 | The error 110 |
111 | {% endcall %} 112 | 113 | {% block script %} 114 | {% endblock %} 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /share/jupyter/hub/templates/spawn.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block main %} 4 | 5 |
6 |
7 |

Spawner options

8 |
9 |
10 | {% if error_message %} 11 |

12 | Error: {{error_message}} 13 |

14 | {% endif %} 15 |
16 | {{spawner_options_form | safe}} 17 |
18 | 19 |
20 |
21 |
22 | 23 | {% endblock %} 24 | 25 | {% block script %} 26 | 32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /share/jupyter/hub/templates/spawn_pending.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block main %} 4 | 5 |
6 |
7 |
8 |

Your server is starting up.

9 |

You will be redirected automatically when it's ready for you.

10 | refresh 11 |
12 |
13 |
14 | 15 | {% endblock %} 16 | 17 | {% block script %} 18 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /tools/tasks.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | invoke script for releasing jupyterhub 4 | 5 | usage: 6 | 7 | invoke release 1.2.3 [--upload] 8 | 9 | This does: 10 | 11 | - clone into /tmp/jupyterhub-repo 12 | - patches version.py with release version 13 | - creates tag (push if uploading) 14 | - makes a virtualenv with python3.4 (PYTHON_EXE env to override) 15 | - builds an sdist (optionally uploads) 16 | - patches version.py with post-release version (X.Y+1.Z.dev) (push if uploading) 17 | - unpacks sdist to /tmp/jupyterhub-release 18 | - builds bdist_wheel from sdist (optional upload) 19 | 20 | """ 21 | # derived from PyZMQ release/tasks.py (used under BSD) 22 | 23 | # Copyright (c) Jupyter Developers 24 | # Distributed under the terms of the Modified BSD License. 25 | 26 | import glob 27 | import os 28 | import pipes 29 | import shutil 30 | 31 | from contextlib import contextmanager 32 | from distutils.version import LooseVersion as V 33 | 34 | from invoke import task, run as invoke_run 35 | 36 | pjoin = os.path.join 37 | here = os.path.dirname(__file__) 38 | 39 | repo = "git@github.com:jupyter/jupyterhub" 40 | pkg = repo.rsplit('/', 1)[-1] 41 | 42 | py_exe = os.environ.get('PYTHON_EXE', 'python3.4') 43 | 44 | tmp = "/tmp" 45 | env_root = os.path.join(tmp, 'envs') 46 | repo_root = pjoin(tmp, '%s-repo' % pkg) 47 | sdist_root = pjoin(tmp, '%s-release' % pkg) 48 | 49 | 50 | def run(cmd, **kwargs): 51 | """wrapper around invoke.run that accepts a Popen list""" 52 | if isinstance(cmd, list): 53 | cmd = " ".join(pipes.quote(s) for s in cmd) 54 | kwargs.setdefault('echo', True) 55 | return invoke_run(cmd, **kwargs) 56 | 57 | 58 | @contextmanager 59 | def cd(path): 60 | """Context manager for temporary CWD""" 61 | cwd = os.getcwd() 62 | os.chdir(path) 63 | try: 64 | yield 65 | finally: 66 | os.chdir(cwd) 67 | 68 | 69 | @task 70 | def clone_repo(reset=False): 71 | """Clone the repo""" 72 | if os.path.exists(repo_root) and reset: 73 | shutil.rmtree(repo_root) 74 | if os.path.exists(repo_root): 75 | with cd(repo_root): 76 | run("git pull") 77 | else: 78 | run("git clone %s %s" % (repo, repo_root)) 79 | 80 | 81 | @task 82 | def patch_version(vs, path=pjoin(here, '..')): 83 | """Patch zmq/sugar/version.py for the current release""" 84 | v = parse_vs(vs) 85 | version_py = pjoin(path, 'jupyterhub', 'version.py') 86 | print("patching %s with %s" % (version_py, vs)) 87 | # read version.py, minus version parts 88 | with open(version_py) as f: 89 | pre_lines = [] 90 | post_lines = [] 91 | for line in f: 92 | pre_lines.append(line) 93 | if line.startswith("version_info"): 94 | break 95 | for line in f: 96 | if line.startswith(')'): 97 | post_lines.append(line) 98 | break 99 | for line in f: 100 | post_lines.append(line) 101 | 102 | # write new version.py 103 | with open(version_py, 'w') as f: 104 | for line in pre_lines: 105 | f.write(line) 106 | for part in v: 107 | f.write(' %r,\n' % part) 108 | for line in post_lines: 109 | f.write(line) 110 | 111 | # verify result 112 | ns = {} 113 | with open(version_py) as f: 114 | exec(f.read(), {}, ns) 115 | assert ns['__version__'] == vs, "%r != %r" % (ns['__version__'], vs) 116 | 117 | 118 | @task 119 | def tag(vs, push=False): 120 | """Make the tagged release commit""" 121 | patch_version(vs, repo_root) 122 | with cd(repo_root): 123 | run('git commit -a -m "release {}"'.format(vs)) 124 | run('git tag -a -m "release {0}" {0}'.format(vs)) 125 | if push: 126 | run('git push') 127 | run('git push --tags') 128 | 129 | 130 | @task 131 | def untag(vs, push=False): 132 | """Make the post-tag 'back to dev' commit""" 133 | v2 = parse_vs(vs) 134 | v2.append('dev') 135 | v2[1] += 1 136 | v2[2] = 0 137 | vs2 = unparse_vs(v2) 138 | patch_version(vs2, repo_root) 139 | with cd(repo_root): 140 | run('git commit -a -m "back to dev"') 141 | if push: 142 | run('git push') 143 | 144 | 145 | def make_env(*packages): 146 | """Make a virtualenv 147 | 148 | Assumes `which python` has the `virtualenv` package 149 | """ 150 | if not os.path.exists(env_root): 151 | os.makedirs(env_root) 152 | 153 | env = os.path.join(env_root, os.path.basename(py_exe)) 154 | py = pjoin(env, 'bin', 'python') 155 | # new env 156 | if not os.path.exists(py): 157 | run('python -m virtualenv {} -p {}'.format( 158 | pipes.quote(env), 159 | pipes.quote(py_exe), 160 | )) 161 | py = pjoin(env, 'bin', 'python') 162 | run([py, '-V']) 163 | install(py, 'pip', 'setuptools') 164 | if packages: 165 | install(py, *packages) 166 | return py 167 | 168 | 169 | def build_sdist(py): 170 | """Build sdists 171 | 172 | Returns the path to the tarball 173 | """ 174 | with cd(repo_root): 175 | cmd = [py, 'setup.py', 'sdist', '--formats=gztar'] 176 | run(cmd) 177 | 178 | return glob.glob(pjoin(repo_root, 'dist', '*.tar.gz'))[0] 179 | 180 | 181 | @task 182 | def sdist(vs, upload=False): 183 | clone_repo() 184 | tag(vs, push=upload) 185 | py = make_env() 186 | tarball = build_sdist(py) 187 | if upload: 188 | with cd(repo_root): 189 | install(py, 'twine') 190 | run([py, '-m', 'twine', 'upload', 'dist/*']) 191 | 192 | untag(vs, push=upload) 193 | return untar(tarball) 194 | 195 | 196 | def install(py, *packages): 197 | run([py, '-m', 'pip', 'install', '--upgrade'] + list(packages)) 198 | 199 | 200 | def parse_vs(vs): 201 | """version string to list""" 202 | return V(vs).version 203 | 204 | 205 | def unparse_vs(tup): 206 | """version list to string""" 207 | return '.'.join(map(str, tup)) 208 | 209 | 210 | def untar(tarball): 211 | """extract sdist, returning path to unpacked package directory""" 212 | if os.path.exists(sdist_root): 213 | shutil.rmtree(sdist_root) 214 | os.makedirs(sdist_root) 215 | with cd(sdist_root): 216 | run(['tar', '-xzf', tarball]) 217 | 218 | return glob.glob(pjoin(sdist_root, '*'))[0] 219 | 220 | 221 | def bdist(): 222 | """build a wheel, optionally uploading it""" 223 | py = make_env('wheel') 224 | run([py, 'setup.py', 'bdist_wheel']) 225 | 226 | 227 | @task 228 | def release(vs, upload=False): 229 | """Release the package""" 230 | # start from scrach with clone and envs 231 | clone_repo(reset=True) 232 | if os.path.exists(env_root): 233 | shutil.rmtree(env_root) 234 | 235 | path = sdist(vs, upload=upload) 236 | print("Working in %r" % path) 237 | with cd(path): 238 | bdist() 239 | if upload: 240 | py = make_env('twine') 241 | run([py, '-m', 'twine', 'upload', 'dist/*']) 242 | 243 | --------------------------------------------------------------------------------