diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..63568422 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[run] +omit = + jupyterhub/tests/* + jupyterhub/singleuser.py diff --git a/.dockerignore b/.dockerignore index 25374c2e..cef56b08 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,4 @@ bench jupyterhub_cookie_secret jupyterhub.sqlite jupyterhub_config.py +node_modules diff --git a/.gitignore b/.gitignore index de3322be..dd6fdf8e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ node_modules .DS_Store build dist +docs/_build +.ipynb_checkpoints # ignore config file at the top-level of the repo # but not sub-dirs /jupyterhub_config.py @@ -14,3 +16,6 @@ share/jupyter/hub/static/css/style.min.css share/jupyter/hub/static/css/style.min.css.map *.egg-info MANIFEST +.coverage +htmlcov + diff --git a/.travis.yml b/.travis.yml index 03270c67..1a904b89 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,7 @@ language: python sudo: false python: + - 3.5 - 3.4 - 3.3 before_install: @@ -10,6 +11,11 @@ before_install: - git clone --quiet --depth 1 https://github.com/minrk/travis-wheels travis-wheels install: - pip install -f travis-wheels/wheelhouse -r dev-requirements.txt . - - pip install -f travis-wheels/wheelhouse ipython[notebook] script: - - py.test jupyterhub + - py.test --cov jupyterhub jupyterhub/tests -v +after_success: + - codecov +matrix: + include: + - python: 3.5 + env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://127.0.0.1.xip.io:8000 diff --git a/Dockerfile b/Dockerfile index 640e0ae0..80f29079 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,24 +5,39 @@ # FROM jupyter/jupyterhub:latest # -FROM ipython/ipython +FROM debian:jessie MAINTAINER Jupyter Project +# install nodejs, utf8 locale +ENV DEBIAN_FRONTEND noninteractive +RUN apt-get -y update && \ + apt-get -y upgrade && \ + apt-get -y install npm nodejs nodejs-legacy wget locales git &&\ + /usr/sbin/update-locale LANG=C.UTF-8 && \ + locale-gen C.UTF-8 && \ + apt-get remove -y locales && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* +ENV LANG C.UTF-8 + +# install Python with conda +RUN wget -q https://repo.continuum.io/miniconda/Miniconda3-3.9.1-Linux-x86_64.sh -O /tmp/miniconda.sh && \ + bash /tmp/miniconda.sh -f -b -p /opt/conda && \ + /opt/conda/bin/conda install --yes python=3.5 sqlalchemy tornado jinja2 traitlets requests pip && \ + /opt/conda/bin/pip install --upgrade pip && \ + rm /tmp/miniconda.sh +ENV PATH=/opt/conda/bin:$PATH + # install js dependencies -RUN npm install -g configurable-http-proxy - -RUN mkdir -p /srv/ - -# install jupyterhub -ADD requirements.txt /tmp/requirements.txt -RUN pip3 install -r /tmp/requirements.txt +RUN npm install -g configurable-http-proxy && rm -rf ~/.npm WORKDIR /srv/ ADD . /srv/jupyterhub WORKDIR /srv/jupyterhub/ -RUN pip3 install . +RUN python setup.py js && pip install . && \ + rm -rf node_modules ~/.cache ~/.npm WORKDIR /srv/jupyterhub/ @@ -31,5 +46,7 @@ WORKDIR /srv/jupyterhub/ EXPOSE 8000 +LABEL org.jupyter.service="jupyterhub" + ONBUILD ADD jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"] diff --git a/README.md b/README.md index 126af9af..9c878e8f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ # JupyterHub: A multi-user server for Jupyter notebooks -JupyterHub is a multi-user server that manages and proxies multiple instances of the single-user IPython Jupyter notebook server. +Questions, comments? Visit our Google Group: + +[![Google Group](https://img.shields.io/badge/-Google%20Group-lightgrey.svg)](https://groups.google.com/forum/#!forum/jupyter) +[![Build Status](https://travis-ci.org/jupyter/jupyterhub.svg?branch=master)](https://travis-ci.org/jupyter/jupyterhub) +[![Circle CI](https://circleci.com/gh/jupyter/jupyterhub.svg?style=shield&circle-token=b5b65862eb2617b9a8d39e79340b0a6b816da8cc)](https://circleci.com/gh/jupyter/jupyterhub) +[![Documentation Status](https://readthedocs.org/projects/jupyterhub/badge/?version=latest)](http://jupyterhub.readthedocs.org/en/latest/?badge=latest) + +JupyterHub, a multi-user server, manages and proxies multiple instances of the single-user IPython Jupyter notebook server. Three actors: @@ -18,48 +25,60 @@ Basic principles: ## Dependencies -JupyterHub requires IPython >= 3.0 (current master) and Python >= 3.3. +JupyterHub itself requires [Python](https://www.python.org/downloads/) ≥ 3.3. To run the single-user servers (which may be on the same system as the Hub or not), [Jupyter Notebook](https://jupyter.readthedocs.org/en/latest/install.html) ≥ 4 is required. -You will need nodejs/npm, which you can get from your package manager: +Install [nodejs/npm](https://www.npmjs.com/), which is available from your +package manager. For example, install on Linux (Debian/Ubuntu) using: sudo apt-get install npm nodejs-legacy -(The `nodejs-legacy` package installs the `node` executable, -which is required for npm to work on Debian/Ubuntu at this point) +(The `nodejs-legacy` package installs the `node` executable and is currently +required for npm to work on Debian/Ubuntu.) -Then install javascript dependencies: +Next, install JavaScript dependencies: sudo npm install -g configurable-http-proxy +### (Optional) Installation Prerequisite (pip) + +Notes on the `pip` command used in the installation directions below: +- `sudo` may be needed for `pip install`, depending on the user's filesystem permissions. +- JupyterHub requires Python >= 3.3, so `pip3` may be required on some machines for package installation instead of `pip` (especially when both Python 2 and Python 3 are installed on a machine). If `pip3` is not found, install it using (on Linux Debian/Ubuntu): + + sudo apt-get install python3-pip + ## Installation -Then you can install the Python package by doing: +JupyterHub can be installed with pip, and the proxy with npm: - pip install -r requirements.txt - pip install . + npm install -g configurable-http-proxy + pip3 install jupyterhub -If you plan to run notebook servers locally, you may also need to install the IPython notebook: +If you plan to run notebook servers locally, you may also need to install the +Jupyter ~~IPython~~ notebook: - pip install "ipython[notebook]" - - -This will fetch client-side javascript dependencies and compile CSS, -and install these files to `sys.prefix`/share/jupyter, as well as -install any Python dependencies. + pip3 install --upgrade notebook ### Development install -For a development install: +For a development install, clone the repository and then install from source: - pip install -r dev-requirements.txt - pip install -e . + git clone https://github.com/jupyter/jupyterhub + cd jupyterhub + pip3 install -r dev-requirements.txt -e . -In which case you may need to manually update javascript and css after some updates, with: +If the `pip3 install` command fails and complains about `lessc` being unavailable, you may need to explicitly install some additional JavaScript dependencies: - python setup.py js # fetch updated client-side js (changes rarely) - python setup.py css # recompile CSS from LESS sources + npm install + +This will fetch client-side JavaScript dependencies necessary to compile CSS. + +You may also need to manually update JavaScript and CSS after some development updates, with: + + python3 setup.py js # fetch updated client-side js (changes rarely) + python3 setup.py css # recompile CSS from LESS sources ## Running the server @@ -70,18 +89,24 @@ To start the server, run the command: and then visit `http://localhost:8000`, and sign in with your unix credentials. -If you want multiple users to be able to sign into the server, you will need to run the -`jupyterhub` command as a privileged user, such as root. -The [wiki](https://github.com/jupyter/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges) describes how to run the server -as a less privileged user, which requires more configuration of the system. +To allow multiple users to sign into the server, you will need to +run the `jupyterhub` command as a *privileged user*, such as root. +The [wiki](https://github.com/jupyter/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges) +describes how to run the server as a *less privileged user*, which requires more +configuration of the system. + +## Getting started + +See the [getting started document](docs/source/getting-started.md) for the +basics of configuring your JupyterHub deployment. ### Some examples -generate a default config file: +Generate a default config file: jupyterhub --generate-config -spawn the server on 10.0.1.2:443 with https: +Spawn the server on ``10.0.1.2:443`` with **https**: jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert @@ -90,4 +115,37 @@ which should allow plugging into a variety of authentication or process control Some examples, meant as illustration and testing of this concept: - Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyter/oauthenticator) -- Spawning single-user servers with docker, using the [DockerSpawner](https://github.com/jupyter/dockerspawner) +- Spawning single-user servers with Docker, using the [DockerSpawner](https://github.com/jupyter/dockerspawner) + +### Docker + +There is a ready to go [docker image](https://hub.docker.com/r/jupyter/jupyterhub/). +It can be started with the following command: + + docker run -d --name jupyter.cont [-v /home/jupyter-home:/home] jupyter/jupyterhub jupyterhub + +This command will create a container named `jupyter.cont` that you can stop and resume with `docker stop/start`. +It will be listening on all interfaces at port 8000. So this is perfect to test docker on your desktop or laptop. +If you want to run docker on a computer that has a public IP then you should (as in MUST) secure it with ssl by +adding ssl options to your docker configuration or using a ssl enabled proxy. The `-v/--volume` option will +allow you to store data outside the docker image (host system) so it will be persistent, even when you start +a new image. The command `docker exec -it jupyter.cont bash` will spawn a root shell in your started docker +container. You can use it to create system users in the container. These accounts will be used for authentication +in jupyterhub's default configuration. In order to run without SSL, you'll need to set `--no-ssl` explicitly. + +# Getting help + +We encourage you to ask questions on the mailing list: + +[![Google Group](https://img.shields.io/badge/-Google%20Group-lightgrey.svg)](https://groups.google.com/forum/#!forum/jupyter) + +and you may participate in development discussions or get live help on Gitter: + +[![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/jupyter/jupyterhub?utm_source=badge&utm_medium=badge) + +## Resources +- [Project Jupyter website](https://jupyter.org) +- [Documentation for JupyterHub](http://jupyterhub.readthedocs.org/en/latest/) [[PDF](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf)] +- [Documentation for Project Jupyter](http://jupyter.readthedocs.org/en/latest/index.html) [[PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)] +- [Issues](https://github.com/jupyter/jupyterhub/issues) +- [Technical support - Jupyter Google Group](https://groups.google.com/forum/#!forum/jupyter) diff --git a/circle.yml b/circle.yml new file mode 100644 index 00000000..0a3a1227 --- /dev/null +++ b/circle.yml @@ -0,0 +1,11 @@ +machine: + services: + - docker + +dependencies: + override: + - ls + +test: + override: + - docker build -t jupyter/jupyterhub . diff --git a/dev-requirements.txt b/dev-requirements.txt index 26b77f68..0fca4115 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,2 +1,5 @@ -r requirements.txt -pytest +codecov +pytest-cov +pytest>=2.8 +notebook diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..aae144bf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,192 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/JupyterHub.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/JupyterHub.qhc" + +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/JupyterHub" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/JupyterHub" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..15314bee --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,263 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source +set I18NSPHINXOPTS=%SPHINXOPTS% source +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 1>NUL 2>NUL +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\JupyterHub.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\JupyterHub.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +:end diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..aa528203 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,3 @@ +-r ../requirements.txt +sphinx +recommonmark==0.4.0 \ No newline at end of file diff --git a/docs/rest-api.yml b/docs/rest-api.yml new file mode 100644 index 00000000..1dd95069 --- /dev/null +++ b/docs/rest-api.yml @@ -0,0 +1,259 @@ +# see me at: http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/master/docs/rest-api.yml#/default +swagger: '2.0' +info: + title: JupyterHub + description: The REST API for JupyterHub + version: 0.4.0 +schemes: + - http +securityDefinitions: + token: + type: apiKey + name: Authorization + in: header +security: + - token: [] +basePath: /hub/api/ +produces: + - application/json +consumes: + - application/json +paths: + /users: + get: + summary: List users + responses: + '200': + description: The user list + schema: + type: array + items: + $ref: '#/definitions/User' + post: + summary: Create multiple users + parameters: + - name: data + in: body + required: true + schema: + type: object + properties: + usernames: + type: array + description: list of usernames to create + items: + type: string + admin: + description: whether the created users should be admins + type: boolean + responses: + '201': + description: The users have been created + schema: + type: array + description: The created users + items: + $ref: '#/definitions/User' + /users/{name}: + get: + summary: Get a user by name + parameters: + - name: name + description: username + in: path + required: true + type: string + responses: + '200': + description: The User model + schema: + $ref: '#/definitions/User' + post: + summary: Create a single user + parameters: + - name: name + description: username + in: path + required: true + type: string + responses: + '201': + description: The user has been created + schema: + $ref: '#/definitions/User' + delete: + summary: Delete a user + parameters: + - name: name + description: username + in: path + required: true + type: string + responses: + '204': + description: The user has been deleted + patch: + summary: Modify a user + description: Change a user's name or admin status + parameters: + - name: name + description: username + in: path + required: true + type: string + - name: data + in: body + required: true + description: Updated user info. At least one of name and admin is required. + schema: + type: object + properties: + name: + type: string + description: the new name (optional) + admin: + type: boolean + description: update admin (optional) + responses: + '200': + description: The updated user info + schema: + $ref: '#/definitions/User' + /users/{name}/server: + post: + summary: Start a user's server + parameters: + - name: name + description: username + in: path + required: true + type: string + responses: + '201': + description: The server has started + '202': + description: The server has been requested, but has not yet started + delete: + summary: Stop a user's server + parameters: + - name: name + description: username + in: path + required: true + type: string + responses: + '204': + description: The server has stopped + '202': + description: The server has been asked to stop, but is taking a while + /users/{name}/admin-access: + post: + summary: Grant an admin access to this user's server + parameters: + - name: name + description: username + in: path + required: true + type: string + responses: + '200': + description: Sets a cookie granting the requesting admin access to the user's server + /proxy: + get: + summary: Get the proxy's routing table + description: A convenience alias for getting the info directly from the proxy + responses: + '200': + description: Routing table + schema: + type: object + description: configurable-http-proxy routing table (see CHP docs for details) + post: + summary: Force the Hub to sync with the proxy + responses: + '200': + description: Success + patch: + summary: Tell the Hub about a new proxy + description: If you have started a new proxy and would like the Hub to switch over to it, this allows you to notify the Hub of the new proxy. + parameters: + - name: data + in: body + required: true + description: Any values that have changed for the new proxy. All keys are optional. + schema: + type: object + properties: + ip: + type: string + description: IP address of the new proxy + port: + type: string + description: Port of the new proxy + protocol: + type: string + description: Protocol of new proxy, if changed + auth_token: + type: string + description: CONFIGPROXY_AUTH_TOKEN for the new proxy + responses: + '200': + description: Success + /authorizations/token/{token}: + get: + summary: Identify a user from an API token + parameters: + - name: token + in: path + required: true + type: string + responses: + '200': + description: The user identified by the API token + schema: + $ref: '#!/definitions/User' + /authorizations/cookie/{cookie_name}/{cookie_value}: + get: + summary: Identify a user from a cookie + description: Used by single-user servers to hand off cookie authentication to the Hub + parameters: + - name: cookie_name + in: path + required: true + type: string + - name: cookie_value + in: path + required: true + type: string + responses: + '200': + description: The user identified by the cookie + schema: + $ref: '#!/definitions/User' + /shutdown: + post: + summary: Shutdown the Hub + responses: + '200': + description: Hub has shutdown +definitions: + User: + type: object + properties: + name: + type: string + description: The user's name + admin: + type: boolean + description: Whether the user is an admin + server: + type: string + description: The user's server's base URL, if running; null if not. + pending: + type: string + enum: ["spawn", "stop"] + description: The currently pending action, if any + last_activity: + type: string + format: ISO8601 Timestamp + description: Timestamp of last-seen activity from the user diff --git a/docs/source/api/auth.rst b/docs/source/api/auth.rst new file mode 100644 index 00000000..6ea06404 --- /dev/null +++ b/docs/source/api/auth.rst @@ -0,0 +1,21 @@ +============== +Authenticators +============== + +Module: :mod:`jupyterhub.auth` +============================== + +.. automodule:: jupyterhub.auth + +.. currentmodule:: jupyterhub.auth + + + +.. autoclass:: Authenticator + :members: + +.. autoclass:: LocalAuthenticator + :members: + +.. autoclass:: PAMAuthenticator + diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst new file mode 100644 index 00000000..ccb14fd3 --- /dev/null +++ b/docs/source/api/index.rst @@ -0,0 +1,14 @@ +.. _api-index: + +#################### + The JupyterHub API +#################### + +:Release: |release| +:Date: |today| + +.. toctree:: + + auth + spawner + user diff --git a/docs/source/api/spawner.rst b/docs/source/api/spawner.rst new file mode 100644 index 00000000..15cb847d --- /dev/null +++ b/docs/source/api/spawner.rst @@ -0,0 +1,18 @@ +============== + Spawners +============== + +Module: :mod:`jupyterhub.spawner` +================================= + +.. automodule:: jupyterhub.spawner + +.. currentmodule:: jupyterhub.spawner + +:class:`Spawner` +---------------- + +.. autoclass:: Spawner + :members: options_from_form, poll, start, stop, get_args, get_env, get_state + +.. autoclass:: LocalProcessSpawner diff --git a/docs/source/api/user.rst b/docs/source/api/user.rst new file mode 100644 index 00000000..2e7b31cf --- /dev/null +++ b/docs/source/api/user.rst @@ -0,0 +1,31 @@ +============= + Users +============= + +Module: :mod:`jupyterhub.user` +============================== + +.. automodule:: jupyterhub.user + +.. currentmodule:: jupyterhub.user + +:class:`User` +------------- + +.. class:: Server + +.. autoclass:: User + :members: escaped_name + + .. attribute:: name + + The user's name + + .. attribute:: server + + The user's Server data object if running, None otherwise. + Has ``ip``, ``port`` attributes. + + .. attribute:: spawner + + The user's :class:`~.Spawner` instance. diff --git a/docs/authenticators.md b/docs/source/authenticators.md similarity index 63% rename from docs/authenticators.md rename to docs/source/authenticators.md index 6e3d370b..74a247a7 100644 --- a/docs/authenticators.md +++ b/docs/source/authenticators.md @@ -63,6 +63,37 @@ For local user authentication (e.g. PAM), this lets you limit which users can login. +## Normalizing and validating usernames + +Since the Authenticator and Spawner both use the same username, +sometimes you want to transform the name coming from the authentication service +(e.g. turning email addresses into local system usernames) before adding them to the Hub service. +Authenticators can define `normalize_username`, which takes a username. +The default normalization is to cast names to lowercase + +For simple mappings, a configurable dict `Authenticator.username_map` is used to turn one name into another: + +```python +c.Authenticator.username_map = { + 'service-name': 'localname' +} +``` + +### Validating usernames + +In most cases, there is a very limited set of acceptable usernames. +Authenticators can define `validate_username(username)`, +which should return True for a valid username and False for an invalid one. +The primary effect this has is improving error messages during user creation. + +The default behavior is to use configurable `Authenticator.username_pattern`, +which is a regular expression string for validation. + +To only allow usernames that start with 'w': + + c.Authenticator.username_pattern = r'w.*' + + ## OAuth and other non-password logins Some login mechanisms, such as [OAuth][], don't map onto username+password. @@ -72,9 +103,9 @@ You can see an example implementation of an Authenticator that uses [GitHub OAut at [OAuthenticator][]. -[Authenticator]: ../jupyterhub/auth.py -[PAM]: http://en.wikipedia.org/wiki/Pluggable_authentication_module -[OAuth]: http://en.wikipedia.org/wiki/OAuth +[Authenticator]: https://github.com/jupyter/jupyterhub/blob/master/jupyterhub/auth.py +[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module +[OAuth]: https://en.wikipedia.org/wiki/OAuth [GitHub OAuth]: https://developer.github.com/v3/oauth/ [OAuthenticator]: https://github.com/jupyter/oauthenticator diff --git a/docs/source/changelog.md b/docs/source/changelog.md new file mode 100644 index 00000000..8c397bd7 --- /dev/null +++ b/docs/source/changelog.md @@ -0,0 +1,45 @@ +# Summary of changes in JupyterHub + +See `git log` for a more detailed summary. + +## 0.5 + +- Single-user server must be run with Jupyter Notebook ≥ 4.0 +- Require `--no-ssl` confirmation to allow the Hub to be run without SSL (e.g. behind SSL termination in nginx) + + +## 0.4 + +### 0.4.1 + +Fix removal of `/login` page in 0.4.0, breaking some OAuth providers. + +### 0.4.0 + +- Add `Spawner.user_options_form` for specifying an HTML form to present to users, + allowing users to influence the spawning of their own servers. +- Add `Authenticator.pre_spawn_start` and `Authenticator.post_spawn_stop` hooks, + so that Authenticators can do setup or teardown (e.g. passing credentials to Spawner, + mounting data sources, etc.). + These methods are typically used with custom Authenticator+Spawner pairs. +- 0.4 will be the last JupyterHub release where single-user servers running IPython 3 is supported instead of Notebook ≥ 4.0. + + +## 0.3 + +- No longer make the user starting the Hub an admin +- start PAM sessions on login +- hooks for Authenticators to fire before spawners start and after they stop, + allowing deeper interaction between Spawner/Authenticator pairs. +- login redirect fixes + +## 0.2 + +- Based on standalone traitlets instead of IPython.utils.traitlets +- multiple users in admin panel +- Fixes for usernames that require escaping + +## 0.1 + +First preview release + diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 00000000..26a16881 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,386 @@ +# -*- coding: utf-8 -*- +# +# JupyterHub documentation build configuration file, created by +# sphinx-quickstart on Mon Jan 4 16:31:09 2016. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os +import shlex + +# Needed for conversion from markdown to html +import recommonmark.parser + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +needs_sphinx = '1.3' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.napoleon', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# Jupyter uses recommonmark's parser to convert markdown +source_parsers = { + '.md': 'recommonmark.parser.CommonMarkParser', +} + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# source_suffix = ['.rst', '.md'] +source_suffix = ['.rst', '.md'] + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'JupyterHub' +copyright = u'2016, Project Jupyter team' +author = u'Project Jupyter team' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# Project Jupyter uses the following to autopopulate version +from os.path import dirname +root = dirname(dirname(dirname(__file__))) +sys.path.insert(0, root) + +import jupyterhub +# The short X.Y version. +version = '%i.%i' % jupyterhub.version_info[:2] +# The full version, including alpha/beta/rc tags. +release = jupyterhub.__version__ + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = [] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' +#html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# Now only 'ja' uses this config value +#html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +#html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'JupyterHubdoc' + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', + +# Latex figure (float) alignment +#'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'JupyterHub.tex', u'JupyterHub Documentation', + u'Project Jupyter team', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'jupyterhub', u'JupyterHub Documentation', + [author], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'JupyterHub', u'JupyterHub Documentation', + author, 'JupyterHub', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False + + +# -- Options for Epub output ---------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project +epub_author = author +epub_publisher = author +epub_copyright = copyright + +# The basename for the epub file. It defaults to the project name. +#epub_basename = project + +# The HTML theme for the epub output. Since the default themes are not optimized +# for small screen space, using the same theme for HTML and epub output is +# usually not wise. This defaults to 'epub', a theme designed to save visual +# space. +#epub_theme = 'epub' + +# The language of the text. It defaults to the language option +# or 'en' if the language is not set. +#epub_language = '' + +# The scheme of the identifier. Typical schemes are ISBN or URL. +#epub_scheme = '' + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +#epub_identifier = '' + +# A unique identification for the text. +#epub_uid = '' + +# A tuple containing the cover image and cover page html template filenames. +#epub_cover = () + +# A sequence of (type, uri, title) tuples for the guide element of content.opf. +#epub_guide = () + +# HTML files that should be inserted before the pages created by sphinx. +# The format is a list of tuples containing the path and title. +#epub_pre_files = [] + +# HTML files shat should be inserted after the pages created by sphinx. +# The format is a list of tuples containing the path and title. +#epub_post_files = [] + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + +# The depth of the table of contents in toc.ncx. +#epub_tocdepth = 3 + +# Allow duplicate toc entries. +#epub_tocdup = True + +# Choose between 'default' and 'includehidden'. +#epub_tocscope = 'default' + +# Fix unsupported image types using the Pillow. +#epub_fix_images = False + +# Scale large images. +#epub_max_image_width = 0 + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#epub_show_urls = 'inline' + +# If false, no index is generated. +#epub_use_index = True + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'https://docs.python.org/': None} + +# Read The Docs +# on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org +on_rtd = os.environ.get('READTHEDOCS', None) == 'True' + +if not on_rtd: # only import and set the theme if we're building docs locally + import sphinx_rtd_theme + html_theme = 'sphinx_rtd_theme' + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + +# otherwise, readthedocs.org uses their theme by default, so no need to specify it diff --git a/docs/source/getting-started.md b/docs/source/getting-started.md new file mode 100644 index 00000000..746c57d9 --- /dev/null +++ b/docs/source/getting-started.md @@ -0,0 +1,416 @@ +# Getting started with JupyterHub + +This document describes some of the basics of configuring JupyterHub to do what you want. +JupyterHub is highly customizable, so there's a lot to cover. + + +## Installation + +See [the readme](https://github.com/jupyter/jupyterhub/blob/master/README.md) for help installing JupyterHub. + + +## Overview + +JupyterHub is a set of processes that together provide a multiuser Jupyter Notebook server. +There are three main categories of processes run by the `jupyterhub` command line program: + +- **Single User Server**: a dedicated, single-user, Jupyter Notebook is started for each user on the system + when they log in. The object that starts these processes is called a Spawner. +- **Proxy**: the public facing part of the server that uses a dynamic proxy to route HTTP requests + to the Hub and Single User Servers. +- **Hub**: manages user accounts and authentication and coordinates Single Users Servers using a Spawner. + +## JupyterHub's default behavior + +**IMPORTANT:** In its default configuration, JupyterHub runs without SSL encryption (HTTPS). +**You should not run JupyterHub without SSL encryption on a public network.** +See [Security documentation](#Security) for how to configure JupyterHub to use SSL. + +To start JupyterHub in its default configuration, type the following at the command line: + + sudo jupyterhub + +The default Authenticator that ships with JupyterHub authenticates users +with their system name and password (via [PAM][]). +Any user on the system with a password will be allowed to start a single-user notebook server. + +The default Spawner starts servers locally as each user, one dedicated server per user. +These servers listen on localhost, and start in the given user's home directory. + +By default, the **Proxy** listens on all public interfaces on port 8000. +Thus you can reach JupyterHub through either: + + http://localhost:8000 + +or any other public IP or domain pointing to your system. + +In their default configuration, the other services, the **Hub** and **Single-User Servers**, +all communicate with each other on localhost only. + +By default, starting JupyterHub will write two files to disk in the current working directory: + +- `jupyterhub.sqlite` is the sqlite database containing all of the state of the **Hub**. + This file allows the **Hub** to remember what users are running and where, + as well as other information enabling you to restart parts of JupyterHub separately. +- `jupyterhub_cookie_secret` is the encryption key used for securing cookies. + This file needs to persist in order for restarting the Hub server to avoid invalidating cookies. + Conversely, deleting this file and restarting the server effectively invalidates all login cookies. + The cookie secret file is discussed in the [Cookie Secret documentation](#Cookie secret). + +The location of these files can be specified via configuration, discussed below. + + +## How to configure JupyterHub + +JupyterHub is configured in two ways: + +1. Command-line arguments +2. Configuration files + +Type the following for brief information about the command line arguments: + + jupyterhub -h + +or: + + jupyterhub --help-all + +for the full command line help. + +By default, JupyterHub will look for a configuration file (can be missing) +named `jupyterhub_config.py` in the current working directory. +You can create an empty configuration file with + + + jupyterhub --generate-config + +This empty configuration file has descriptions of all configuration variables and their default +values. You can load a specific config file with: + + jupyterhub -f /path/to/jupyterhub_config.py + +See also: [general docs](http://ipython.org/ipython-doc/dev/development/config.html) +on the config system Jupyter uses. + + +## Networking + +### Configuring the Proxy's IP address and port +The Proxy's main IP address setting determines where JupyterHub is available to users. +By default, JupyterHub is configured to be available on all network interfaces +(`''`) on port 8000. **Note**: Use of `'*'` is discouraged for IP configuration; +instead, use of `'0.0.0.0'` is preferred. + +Changing the IP address and port can be done with the following command line +arguments: + + jupyterhub --ip=192.168.1.2 --port=443 + +Or by placing the following lines in a configuration file: + +```python +c.JupyterHub.ip = '192.168.1.2' +c.JupyterHub.port = 443 +``` + +Port 443 is used as an example since 443 is the default port for SSL/HTTPS. + +Configuring only the main IP and port of JupyterHub should be sufficient for most deployments of JupyterHub. +However, more customized scenarios may need additional networking details to +be configured. + +### Configuring the Proxy's REST API communication IP address and port (optional) +The Hub service talks to the proxy via a REST API on a secondary port, +whose network interface and port can be configured separately. +By default, this REST API listens on port 8081 of localhost only. + +If running the Proxy separate from the Hub, +configure the REST API communication IP address and port with: + +```python +# ideally a private network address +c.JupyterHub.proxy_api_ip = '10.0.1.4' +c.JupyterHub.proxy_api_port = 5432 +``` + +### Configuring the Hub if Spawners or Proxy are remote or isolated in containers +The Hub service also listens only on localhost (port 8080) by default. +The Hub needs needs to be accessible from both the proxy and all Spawners. +When spawning local servers, an IP address setting of localhost is fine. +If *either* the Proxy *or* (more likely) the Spawners will be remote or +isolated in containers, the Hub must listen on an IP that is accessible. + +```python +c.JupyterHub.hub_ip = '10.0.1.4' +c.JupyterHub.hub_port = 54321 +``` + +## Security + +**IMPORTANT:** In its default configuration, JupyterHub runs without SSL encryption (HTTPS). +**You should not run JupyterHub without SSL encryption on a public network.** + +Security is the most important aspect of configuring Jupyter. There are three main aspects of the +security configuration: + +1. SSL encryption (to enable HTTPS) +2. Cookie secret (a key for encrypting browser cookies) +3. Proxy authentication token (used for the Hub and other services to authenticate to the Proxy) + +## SSL encryption + +Since JupyterHub includes authentication and allows arbitrary code execution, you should not run +it without SSL (HTTPS). This will require you to obtain an official, trusted SSL certificate or +create a self-signed certificate. Once you have obtained and installed a key and certificate you +need to specify their locations in the configuration file as follows: + +```python +c.JupyterHub.ssl_key = '/path/to/my.key' +c.JupyterHub.ssl_cert = '/path/to/my.cert' +``` + +It is also possible to use letsencrypt (https://letsencrypt.org/) to obtain a free, trusted SSL +certificate. If you run letsencrypt using the default options, the needed configuration is (replace `your.domain.com` by your fully qualified domain name): + +```python +c.JupyterHub.ssl_key = '/etc/letsencrypt/live/your.domain.com/privkey.pem' +c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/your.domain.com/fullchain.pem' +``` + +Some cert files also contain the key, in which case only the cert is needed. It is important that +these files be put in a secure location on your server, where they are not readable by regular +users. + +## Cookie secret + +The cookie secret is an encryption key, used to encrypt the browser cookies used for +authentication. If this value changes for the Hub, all single-user servers must also be restarted. +Normally, this value is stored in a file, the location of which can be specified in a config file +as follows: + +```python +c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/cookie_secret' +``` + +The content of this file should be a long random string. An example would be to generate this +file as: + +```bash +openssl rand -hex 1024 > /srv/jupyterhub/cookie_secret +``` + +In most deployments of JupyterHub, you should point this to a secure location on the file +system, such as `/srv/jupyterhub/cookie_secret`. If the cookie secret file doesn't exist when +the Hub starts, a new cookie secret is generated and stored in the file. The recommended +permissions for the cookie secret file should be 600 (owner-only rw). + +If you would like to avoid the need for files, the value can be loaded in the Hub process from +the `JPY_COOKIE_SECRET` environment variable: + +```bash +export JPY_COOKIE_SECRET=`openssl rand -hex 1024` +``` + +For security reasons, this environment variable should only be visible to the Hub. + +## Proxy authentication token + +The Hub authenticates its requests to the Proxy using a secret token that the Hub and Proxy agree upon. The value of this string should be a random string (for example, generated by `openssl rand -hex 32`). You can pass this value to the Hub and Proxy using either the `CONFIGPROXY_AUTH_TOKEN` environment variable: + +```bash +export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32` +``` + +This environment variable needs to be visible to the Hub and Proxy. + +Or you can set the value in the configuration file: + +```python +c.JupyterHub.proxy_auth_token = '0bc02bede919e99a26de1e2a7a5aadfaf6228de836ec39a05a6c6942831d8fe5' +``` + +If you don't set the Proxy authentication token, the Hub will generate a random key itself, which +means that any time you restart the Hub you **must also restart the Proxy**. If the proxy is a +subprocess of the Hub, this should happen automatically (this is the default configuration). + +Another time you must set the Proxy authentication token yourself is if you want other services, such as [nbgrader](https://github.com/jupyter/nbgrader) to also be able to connect to the Proxy. + +## Configuring authentication + +The default Authenticator uses [PAM][] to authenticate system users with their username and password. +The default behavior of this Authenticator is to allow any user with an account and password on the system to login. +You can restrict which users are allowed to login with `Authenticator.whitelist`: + + +```python +c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'} +``` + +Admin users of JupyterHub have the ability to take actions on users' behalf, +such as stopping and restarting their servers, +and adding and removing new users from the whitelist. +Any users in the admin list are automatically added to the whitelist, +if they are not already present. +The set of initial Admin users can configured as follows: + +```python +c.Authenticator.admin_users = {'mal', 'zoe'} +``` + +If `JupyterHub.admin_access` is True (not default), +then admin users have permission to log in *as other users* on their respective machines, for debugging. +**You should make sure your users know if admin_access is enabled.** + +### Adding and removing users + +Users can be added and removed to the Hub via the admin panel or REST API. These users will be +added to the whitelist and database. Restarting the Hub will not require manually updating the +whitelist in your config file, as the users will be loaded from the database. This means that +after starting the Hub once, it is not sufficient to remove users from the whitelist in your +config file. You must also remove them from the database, either by discarding the database file, +or via the admin UI. + +The default `PAMAuthenticator` is one case of a special kind of authenticator, called a +`LocalAuthenticator`, indicating that it manages users on the local system. When you add a user to +the Hub, a `LocalAuthenticator` checks if that user already exists. Normally, there will be an +error telling you that the user doesn't exist. If you set the configuration value + +```python +c.LocalAuthenticator.create_system_users = True +``` + +however, adding a user to the Hub that doesn't already exist on the system will result in the Hub +creating that user via the system `adduser` command line tool. This option is typically used on +hosted deployments of JupyterHub, to avoid the need to manually create all your users before +launching the service. It is not recommended when running JupyterHub in situations where +JupyterHub users maps directly onto UNIX users. + +## Configuring single-user servers + +Since the single-user server is an instance of `jupyter notebook`, an entire separate +multi-process application, there are many aspect of that server can configure, and a lot of ways +to express that configuration. + +At the JupyterHub level, you can set some values on the Spawner. The simplest of these is +`Spawner.notebook_dir`, which lets you set the root directory for a user's server. This root +notebook directory is the highest level directory users will be able to access in the notebook +dashboard. In this example, the root notebook directory is set to `~/notebooks`, where `~` is +expanded to the user's home directory. + +```python +c.Spawner.notebook_dir = '~/notebooks' +``` + +You can also specify extra command-line arguments to the notebook server with: + +```python +c.Spawner.args = ['--debug', '--profile=PHYS131'] +``` + +This could be used to set the users default page for the single user server: + +```python +c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb'] +``` + +Since the single-user server extends the notebook server application, +it still loads configuration from the `ipython_notebook_config.py` config file. +Each user may have one of these files in `$HOME/.ipython/profile_default/`. +IPython also supports loading system-wide config files from `/etc/ipython/`, +which is the place to put configuration that you want to affect all of your users. + +## External services + +JupyterHub has a REST API that can be used to run external services. +More detail on this API will be added in the future. + +## File locations + +It is recommended to put all of the files used by JupyterHub into standard UNIX filesystem locations. + +* `/srv/jupyterhub` for all security and runtime files +* `/etc/jupyterhub` for all configuration files +* `/var/log` for log files + +## Example + +In the following example, we show a configuration files for a fairly standard JupyterHub deployment with the following assumptions: + +* JupyterHub is running on a single cloud server +* Using SSL on the standard HTTPS port 443 +* You want to use [GitHub OAuth][oauthenticator] for login +* You need the users to exist locally on the server +* You want users' notebooks to be served from `~/assignments` to allow users to browse for notebooks within + other users home directories +* You want the landing page for each user to be a Welcome.ipynb notebook in their assignments directory. +* All runtime files are put into `/srv/jupyterhub` and log files in `/var/log`. + +Let's start out with `jupyterhub_config.py`: + +```python +# jupyterhub_config.py +c = get_config() + +import os +pjoin = os.path.join + +runtime_dir = os.path.join('/srv/jupyterhub') +ssl_dir = pjoin(runtime_dir, 'ssl') +if not os.path.exists(ssl_dir): + os.makedirs(ssl_dir) + + +# https on :443 +c.JupyterHub.port = 443 +c.JupyterHub.ssl_key = pjoin(ssl_dir, 'ssl.key') +c.JupyterHub.ssl_cert = pjoin(ssl_dir, 'ssl.cert') + +# put the JupyterHub cookie secret and state db +# in /var/run/jupyterhub +c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret') +c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite') +# or `--db=/path/to/jupyterhub.sqlite` on the command-line + +# put the log file in /var/log +c.JupyterHub.log_file = '/var/log/jupyterhub.log' + +# use GitHub OAuthenticator for local users + +c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator' +c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL'] +# create system users that don't exist yet +c.LocalAuthenticator.create_system_users = True + +# specify users and admin +c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'} +c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'} + +# start single-user notebook servers in ~/assignments, +# with ~/assignments/Welcome.ipynb as the default landing page +# this config could also be put in +# /etc/ipython/ipython_notebook_config.py +c.Spawner.notebook_dir = '~/assignments' +c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb'] +``` + +Using the GitHub Authenticator [requires a few additional env variables][oauth-setup], +which we will need to set when we launch the server: + +```bash +export GITHUB_CLIENT_ID=github_id +export GITHUB_CLIENT_SECRET=github_secret +export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback +export CONFIGPROXY_AUTH_TOKEN=super-secret +jupyterhub -f /path/to/aboveconfig.py +``` + +# Further reading + +- [Custom Authenticators](./authenticators.html) +- [Custom Spawners](./spawners.html) +- [Troubleshooting](./troubleshooting.html) + + +[oauth-setup]: https://github.com/jupyter/oauthenticator#setup +[oauthenticator]: https://github.com/jupyter/oauthenticator +[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module diff --git a/docs/howitworks.md b/docs/source/howitworks.md similarity index 93% rename from docs/howitworks.md rename to docs/source/howitworks.md index 6b6d9ab5..f3cafa1c 100644 --- a/docs/howitworks.md +++ b/docs/source/howitworks.md @@ -5,7 +5,7 @@ JupyterHub is a multi-user server that manages and proxies multiple instances of There are three basic processes involved: - multi-user Hub (Python/Tornado) -- configurable http proxy (nodejs) +- [configurable http proxy](https://github.com/jupyter/configurable-http-proxy) (node-http-proxy) - multiple single-user IPython notebook servers (Python/IPython/Tornado) The proxy is the only process that listens on a public interface. @@ -51,7 +51,7 @@ Authentication is customizable via the Authenticator class. Authentication can be replaced by any mechanism, such as OAuth, Kerberos, etc. -JupyterHub only ships with [PAM](http://en.wikipedia.org/wiki/Pluggable_authentication_module) authentication, +JupyterHub only ships with [PAM](https://en.wikipedia.org/wiki/Pluggable_authentication_module) authentication, which requires the server to be run as root, or at least with access to the PAM service, which regular users typically do not have diff --git a/docs/source/images/spawn-form.png b/docs/source/images/spawn-form.png new file mode 100644 index 00000000..370338a3 Binary files /dev/null and b/docs/source/images/spawn-form.png differ diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 00000000..53aa93b4 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,93 @@ +JupyterHub +========== + +JupyterHub is a server that gives multiple users access to Jupyter notebooks, +running an independent Jupyter notebook server for each user. + +To use JupyterHub, you need a Unix server (typically Linux) running +somewhere that is accessible to your team on the network. The JupyterHub server +can be on an internal network at your organisation, or it can run on the public +internet (in which case, take care with `security `__). +Users access JupyterHub in a web browser, by going to the IP address or +domain name of the server. + +Different :doc:`authenticators ` control access +to JupyterHub. The default one (pam) uses the user accounts on the server where +JupyterHub is running. If you use this, you will need to create a user account +on the system for each user on your team. Using other authenticators, you can +allow users to sign in with e.g. a Github account, or with any single-sign-on +system your organisation has. + +Next, :doc:`spawners ` control how JupyterHub starts +the individual notebook server for each user. The default spawner will +start a notebook server on the same machine running under their system username. +The other main option is to start each server in a separate container, often +using Docker. + +JupyterHub runs as three separate parts: + +* The multi-user Hub (Python & Tornado) +* A `configurable http proxy `_ (NodeJS) +* Multiple single-user Jupyter notebook servers (Python & Tornado) + +Basic principles: + +* Hub spawns proxy +* Proxy forwards ~all requests to hub by default +* Hub handles login, and spawns single-user servers on demand +* Hub configures proxy to forward url prefixes to single-user servers + + +Contents: + +.. toctree:: + :maxdepth: 2 + :caption: User Documentation + + getting-started + howitworks + +.. toctree:: + :maxdepth: 2 + :caption: Configuration + + authenticators + spawners + troubleshooting + +.. toctree:: + :maxdepth: 1 + :caption: Developer Documentation + + api/index + + +.. toctree:: + :maxdepth: 1 + :caption: Community documentation + + + +.. toctree:: + :maxdepth: 2 + :caption: About JupyterHub + + changelog + +.. toctree:: + :maxdepth: 1 + :caption: Questions? Suggestions? + + Jupyter mailing list + Jupyter website + Stack Overflow - Jupyter + Stack Overflow - Jupyter-notebook + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/docs/source/spawners.md b/docs/source/spawners.md new file mode 100644 index 00000000..c85d30cb --- /dev/null +++ b/docs/source/spawners.md @@ -0,0 +1,160 @@ +# Writing a custom Spawner + +A [Spawner][] starts each single-user notebook server. +The Spawner represents an abstract interface to a process, +and a custom Spawner needs to be able to take three actions: + +- start the process +- poll whether the process is still running +- stop the process + +## Examples +Custom Spawners for JupyterHub can be found on the [JupyterHub wiki](https://github.com/jupyter/jupyterhub/wiki/Spawners). Some examples include: +- [DockerSpawner](https://github.com/jupyter/dockerspawner) for spawning user servers in Docker containers + * dockerspawner.DockerSpawner for spawning identical Docker containers for + each users + * dockerspawner.SystemUserSpawner for spawning Docker containers with an + environment and home directory for each users +- [SudoSpawner](https://github.com/jupyter/sudospawner) enables JupyterHub to + run without being root, by spawning an intermediate process via `sudo` +- [BatchSpawner](https://github.com/mbmilligan/batchspawner) for spawning remote + servers using batch systems +- [RemoteSpawner](https://github.com/zonca/remotespawner) to spawn notebooks + and a remote server and tunnel the port via SSH +- [SwarmSpawner](https://github.com/compmodels/jupyterhub/blob/master/swarmspawner.py) + for spawning containers using Docker Swarm + +## Spawner control methods + +### Spawner.start + +`Spawner.start` should start the single-user server for a single user. +Information about the user can be retrieved from `self.user`, +an object encapsulating the user's name, authentication, and server info. + +When `Spawner.start` returns, it should have stored the IP and port +of the single-user server in `self.user.server`. + +**NOTE:** When writing coroutines, *never* `yield` in between a database change and a commit. + +Most `Spawner.start` functions will look similar to this example: + +```python +def start(self): + self.user.server.ip = 'localhost' # or other host or IP address, as seen by the Hub + self.user.server.port = 1234 # port selected somehow + self.db.commit() # always commit before yield, if modifying db values + yield self._actually_start_server_somehow() +``` + +When `Spawner.start` returns, the single-user server process should actually be running, +not just requested. JupyterHub can handle `Spawner.start` being very slow +(such as PBS-style batch queues, or instantiating whole AWS instances) +via relaxing the `Spawner.start_timeout` config value. + +### Spawner.poll + +`Spawner.poll` should check if the spawner is still running. +It should return `None` if it is still running, +and an integer exit status, otherwise. + +For the local process case, `Spawner.poll` uses `os.kill(PID, 0)` +to check if the local process is still running. + + +### Spawner.stop + +`Spawner.stop` should stop the process. It must be a tornado coroutine, which should return when the process has finished exiting. + +## Spawner state + +JupyterHub should be able to stop and restart without tearing down +single-user notebook servers. To do this task, a Spawner may need to persist +some information that can be restored later. +A JSON-able dictionary of state can be used to store persisted information. + +Unlike start, stop, and poll methods, the state methods must not be coroutines. + +For the single-process case, the Spawner state is only the process ID of the server: + +```python +def get_state(self): + """get the current state""" + state = super().get_state() + if self.pid: + state['pid'] = self.pid + return state + +def load_state(self, state): + """load state from the database""" + super().load_state(state) + if 'pid' in state: + self.pid = state['pid'] + +def clear_state(self): + """clear any state (called after shutdown)""" + super().clear_state() + self.pid = 0 +``` + +## Spawner options form + +(new in 0.4) + +Some deployments may want to offer options to users to influence how their servers are started. +This may include cluster-based deployments, where users specify what resources should be available, +or docker-based deployments where users can select from a list of base images. + +This feature is enabled by setting `Spawner.options_form`, which is an HTML form snippet +inserted unmodified into the spawn form. +If the `Spawner.options_form` is defined, when a user tries to start their server, they will be directed to a form page, like this: + +![spawn-form](images/spawn-form.png) + +If `Spawner.options_form` is undefined, the user's server is spawned directly, and no spawn page is rendered. + +See [this example](https://github.com/jupyter/jupyterhub/blob/master/examples/spawn-form/jupyterhub_config.py) for a form that allows custom CLI args for the local spawner. + + +### `Spawner.options_from_form` + +Options from this form will always be a dictionary of lists of strings, e.g.: + +```python +{ + 'integer': ['5'], + 'text': ['some text'], + 'select': ['a', 'b'], +} +``` + +When `formdata` arrives, it is passed through `Spawner.options_from_form(formdata)`, +which is a method to turn the form data into the correct structure. +This method must return a dictionary, and is meant to interpret the lists-of-strings into the correct types. For example, the `options_from_form` for the above form would look like: + +```python +def options_from_form(self, formdata): + options = {} + options['integer'] = int(formdata['integer'][0]) # single integer value + options['text'] = formdata['text'][0] # single string value + options['select'] = formdata['select'] # list already correct + options['notinform'] = 'extra info' # not in the form at all + return options +``` + +which would return: + +```python +{ + 'integer': 5, + 'text': 'some text', + 'select': ['a', 'b'], + 'notinform': 'extra info', +} +``` + +When `Spawner.spawn` is called, this dictionary is accessible as `self.user_options`. + + + +[Spawner]: https://github.com/jupyter/jupyterhub/blob/master/jupyterhub/spawner.py diff --git a/docs/source/troubleshooting.md b/docs/source/troubleshooting.md new file mode 100644 index 00000000..5c4cd8e2 --- /dev/null +++ b/docs/source/troubleshooting.md @@ -0,0 +1,11 @@ +# Troubleshooting + +This document is under active development. + +## Networking + +If JupyterHub proxy fails to start: + +- check if the JupyterHub IP configuration setting is + ``c.JupyterHub.ip = '*'``; if it is, try ``c.JupyterHub.ip = ''`` +- Try starting with ``jupyterhub --ip=0.0.0.0`` \ No newline at end of file diff --git a/docs/spawners.md b/docs/spawners.md deleted file mode 100644 index ad76b0bc..00000000 --- a/docs/spawners.md +++ /dev/null @@ -1,89 +0,0 @@ -# Writing a custom Spawner - -Each single-user server is started by a [Spawner][]. -The Spawner represents an abstract interface to a process, -and a custom Spawner needs to be able to take three actions: - -1. start the process -2. poll whether the process is still running -3. stop the process - -See a list of custom Spawners [on the wiki](https://github.com/jupyter/jupyterhub/wiki/Spawners). - - -## Spawner.start - -`Spawner.start` should start the single-user server for a single user. -Information about the user can be retrieved from `self.user`, -an object encapsulating the user's name, authentication, and server info. - -When `Spawner.start` returns, it should have stored the IP and port -of the single-user server in `self.user.server`. - -**NOTE:** when writing coroutines, *never* `yield` in between a db change and a commit. -Most `Spawner.start`s should have something looking like: - -```python -def start(self): - self.user.server.ip = 'localhost' # or other host or IP address, as seen by the Hub - self.user.server.port = 1234 # port selected somehow - self.db.commit() # always commit before yield, if modifying db values - yield self._actually_start_server_somehow() -``` - -When `Spawner.start` returns, the single-user server process should actually be running, -not just requested. JupyterHub can handle `Spawner.start` being very slow -(such as PBS-style batch queues, or instantiating whole AWS instances) -via relaxing the `Spawner.start_timeout` config value. - - -## Spawner.poll - -`Spawner.poll` should check if the spawner is still running. -It should return `None` if it is still running, -and an integer exit status, otherwise. - -For the local process case, this uses `os.kill(PID, 0)` -to check if the process is still around. - - -## Spawner.stop - -`Spawner.stop` should stop the process. It must be a tornado coroutine, -and should return when the process has finished exiting. - - -## Spawner state - -JupyterHub should be able to stop and restart without having to teardown -single-user servers. This means that a Spawner may need to persist -some information that it can be restored. -A dictionary of JSON-able state can be used to store this information. - -Unlike start/stop/poll, the state methods must not be coroutines. - -In the single-process case, this is only the process ID of the server: - -```python -def get_state(self): - """get the current state""" - state = super().get_state() - if self.pid: - state['pid'] = self.pid - return state - -def load_state(self, state): - """load state from the database""" - super().load_state(state) - if 'pid' in state: - self.pid = state['pid'] - -def clear_state(self): - """clear any state (called after shutdown)""" - super().clear_state() - self.pid = 0 -``` - - - -[Spawner]: ../jupyterhub/spawner.py diff --git a/examples/spawn-form/jupyterhub_config.py b/examples/spawn-form/jupyterhub_config.py new file mode 100644 index 00000000..0ed1bf1e --- /dev/null +++ b/examples/spawn-form/jupyterhub_config.py @@ -0,0 +1,46 @@ +""" +Example JuptyerHub config allowing users to specify environment variables and notebook-server args +""" +import shlex + +from jupyterhub.spawner import LocalProcessSpawner + +class DemoFormSpawner(LocalProcessSpawner): + def _options_form_default(self): + default_env = "YOURNAME=%s\n" % self.user.name + return """ + + + + + """.format(env=default_env) + + def options_from_form(self, formdata): + options = {} + options['env'] = env = {} + + env_lines = formdata.get('env', ['']) + for line in env_lines[0].splitlines(): + if line: + key, value = line.split('=', 1) + env[key.strip()] = value.strip() + + arg_s = formdata.get('args', [''])[0].strip() + if arg_s: + options['argv'] = shlex.split(arg_s) + return options + + def get_args(self): + """Return arguments to pass to the notebook server""" + argv = super().get_args() + if self.user_options.get('argv'): + argv.extend(self.user_options['argv']) + return argv + + def get_env(self): + env = super().get_env() + if self.user_options.get('env'): + env.update(self.user_options['env']) + return env + +c.JupyterHub.spawner_class = DemoFormSpawner diff --git a/jupyterhub/__init__.py b/jupyterhub/__init__.py index 48af2d06..a7731503 100644 --- a/jupyterhub/__init__.py +++ b/jupyterhub/__init__.py @@ -1,2 +1,2 @@ -from .version import * +from .version import version_info, __version__ diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py index 0525cd1d..aefc98bf 100644 --- a/jupyterhub/apihandlers/auth.py +++ b/jupyterhub/apihandlers/auth.py @@ -4,6 +4,7 @@ # Distributed under the terms of the Modified BSD License. import json +from urllib.parse import quote from tornado import web from .. import orm @@ -17,14 +18,13 @@ class TokenAPIHandler(APIHandler): orm_token = orm.APIToken.find(self.db, token) if orm_token is None: raise web.HTTPError(404) - self.write(json.dumps({ - 'user' : orm_token.user.name, - })) + self.write(json.dumps(self.user_model(self.users[orm_token.user]))) class CookieAPIHandler(APIHandler): @token_authenticated def get(self, cookie_name, cookie_value=None): + cookie_name = quote(cookie_name, safe='') if cookie_value is None: self.log.warn("Cookie values in request body is deprecated, use `/cookie_name/cookie_value`") cookie_value = self.request.body @@ -33,9 +33,7 @@ class CookieAPIHandler(APIHandler): user = self._user_for_cookie(cookie_name, cookie_value) if user is None: raise web.HTTPError(404) - self.write(json.dumps({ - 'user' : user.name, - })) + self.write(json.dumps(self.user_model(user))) default_handlers = [ diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index 1c7f2557..a63233b2 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -9,8 +9,43 @@ from http.client import responses from tornado import web from ..handlers import BaseHandler +from ..utils import url_path_join class APIHandler(BaseHandler): + + def check_referer(self): + """Check Origin for cross-site API requests. + + Copied from WebSocket with changes: + + - allow unspecified host/referer (e.g. scripts) + """ + host = self.request.headers.get("Host") + referer = self.request.headers.get("Referer") + + # If no header is provided, assume it comes from a script/curl. + # We are only concerned with cross-site browser stuff here. + if not host: + self.log.warn("Blocking API request with no host") + return False + if not referer: + self.log.warn("Blocking API request with no referer") + return False + + host_path = url_path_join(host, self.hub.server.base_url) + referer_path = referer.split('://', 1)[-1] + if not (referer_path + '/').startswith(host_path): + self.log.warn("Blocking Cross Origin API request. Referer: %s, Host: %s", + referer, host_path) + return False + return True + + def get_current_user_cookie(self): + """Override get_user_cookie to check Referer header""" + if not self.check_referer(): + return None + return super().get_current_user_cookie() + def get_json_body(self): """Return the body of the request as JSON data.""" if not self.request.body: @@ -23,7 +58,6 @@ class APIHandler(BaseHandler): self.log.error("Couldn't parse JSON", exc_info=True) raise web.HTTPError(400, 'Invalid JSON in body of request') return model - def write_error(self, status_code, **kwargs): """Write JSON errors instead of HTML""" @@ -47,3 +81,38 @@ class APIHandler(BaseHandler): 'status': status_code, 'message': message or status_message, })) + + def user_model(self, user): + model = { + 'name': user.name, + 'admin': user.admin, + 'server': user.url if user.running else None, + 'pending': None, + 'last_activity': user.last_activity.isoformat(), + } + if user.spawn_pending: + model['pending'] = 'spawn' + elif user.stop_pending: + model['pending'] = 'stop' + return model + + _model_types = { + 'name': str, + 'admin': bool, + } + + def _check_user_model(self, model): + if not isinstance(model, dict): + raise web.HTTPError(400, "Invalid JSON data: %r" % model) + if not set(model).issubset(set(self._model_types)): + raise web.HTTPError(400, "Invalid JSON keys: %r" % model) + for key, value in model.items(): + if not isinstance(value, self._model_types[key]): + raise web.HTTPError(400, "user.%s must be %s, not: %r" % ( + key, self._model_types[key], type(value) + )) + + def options(self, *args, **kwargs): + self.set_header('Access-Control-Allow-Headers', 'accept, content-type') + self.finish() + \ No newline at end of file diff --git a/jupyterhub/apihandlers/proxy.py b/jupyterhub/apihandlers/proxy.py index 4d17b8d9..54d75500 100644 --- a/jupyterhub/apihandlers/proxy.py +++ b/jupyterhub/apihandlers/proxy.py @@ -28,7 +28,7 @@ class ProxyAPIHandler(APIHandler): @gen.coroutine def post(self): """POST checks the proxy to ensure""" - yield self.proxy.check_routes() + yield self.proxy.check_routes(self.users) @admin_only @@ -59,7 +59,7 @@ class ProxyAPIHandler(APIHandler): self.proxy.auth_token = model['auth_token'] self.db.commit() self.log.info("Updated proxy at %s", server.bind_url) - yield self.proxy.check_routes() + yield self.proxy.check_routes(self.users) diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 0b122f98..aa4488ae 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -11,44 +11,67 @@ from .. import orm from ..utils import admin_only from .base import APIHandler -class BaseUserHandler(APIHandler): - - def user_model(self, user): - model = { - 'name': user.name, - 'admin': user.admin, - 'server': user.server.base_url if user.running else None, - 'pending': None, - 'last_activity': user.last_activity.isoformat(), - } - if user.spawn_pending: - model['pending'] = 'spawn' - elif user.stop_pending: - model['pending'] = 'stop' - return model - - _model_types = { - 'name': str, - 'admin': bool, - } - - def _check_user_model(self, model): - if not isinstance(model, dict): - raise web.HTTPError(400, "Invalid JSON data: %r" % model) - if not set(model).issubset(set(self._model_types)): - raise web.HTTPError(400, "Invalid JSON keys: %r" % model) - for key, value in model.items(): - if not isinstance(value, self._model_types[key]): - raise web.HTTPError(400, "user.%s must be %s, not: %r" % ( - key, self._model_types[key], type(value) - )) -class UserListAPIHandler(BaseUserHandler): +class UserListAPIHandler(APIHandler): @admin_only def get(self): - users = self.db.query(orm.User) + users = [ self._user_from_orm(u) for u in self.db.query(orm.User) ] data = [ self.user_model(u) for u in users ] self.write(json.dumps(data)) + + @admin_only + @gen.coroutine + def post(self): + data = self.get_json_body() + if not data or not isinstance(data, dict) or not data.get('usernames'): + raise web.HTTPError(400, "Must specify at least one user to create") + + usernames = data.pop('usernames') + self._check_user_model(data) + # admin is set for all users + # to create admin and non-admin users requires at least two API requests + admin = data.get('admin', False) + + to_create = [] + invalid_names = [] + for name in usernames: + name = self.authenticator.normalize_username(name) + if not self.authenticator.validate_username(name): + invalid_names.append(name) + continue + user = self.find_user(name) + if user is not None: + self.log.warn("User %s already exists" % name) + else: + to_create.append(name) + + if invalid_names: + if len(invalid_names) == 1: + msg = "Invalid username: %s" % invalid_names[0] + else: + msg = "Invalid usernames: %s" % ', '.join(invalid_names) + raise web.HTTPError(400, msg) + + if not to_create: + raise web.HTTPError(400, "All %i users already exist" % len(usernames)) + + created = [] + for name in to_create: + user = self.user_from_username(name) + if admin: + user.admin = True + self.db.commit() + try: + yield gen.maybe_future(self.authenticator.add_user(user)) + except Exception as e: + self.log.error("Failed to create user: %s" % name, exc_info=True) + del self.users[user] + raise web.HTTPError(400, "Failed to create user %s: %s" % (name, str(e))) + else: + created.append(user) + + self.write(json.dumps([ self.user_model(u) for u in created ])) + self.set_status(201) def admin_or_self(method): @@ -66,7 +89,7 @@ def admin_or_self(method): return method(self, name) return m -class UserAPIHandler(BaseUserHandler): +class UserAPIHandler(APIHandler): @admin_or_self def get(self, name): @@ -92,8 +115,8 @@ class UserAPIHandler(BaseUserHandler): yield gen.maybe_future(self.authenticator.add_user(user)) except Exception: self.log.error("Failed to create user: %s" % name, exc_info=True) - self.db.delete(user) - self.db.commit() + # remove from registry + del self.users[user] raise web.HTTPError(400, "Failed to create user: %s" % name) self.write(json.dumps(self.user_model(user))) @@ -115,10 +138,8 @@ class UserAPIHandler(BaseUserHandler): raise web.HTTPError(400, "%s's server is in the process of stopping, please wait." % name) yield gen.maybe_future(self.authenticator.delete_user(user)) - - # remove from the db - self.db.delete(user) - self.db.commit() + # remove from registry + del self.users[user] self.set_status(204) @@ -135,7 +156,7 @@ class UserAPIHandler(BaseUserHandler): self.write(json.dumps(self.user_model(user))) -class UserServerAPIHandler(BaseUserHandler): +class UserServerAPIHandler(APIHandler): @gen.coroutine @admin_or_self def post(self, name): @@ -145,7 +166,8 @@ class UserServerAPIHandler(BaseUserHandler): if state is None: raise web.HTTPError(400, "%s's server is already running" % name) - yield self.spawn_single_user(user) + options = self.get_json_body() + yield self.spawn_single_user(user, options=options) status = 202 if user.spawn_pending else 201 self.set_status(status) @@ -165,7 +187,7 @@ class UserServerAPIHandler(BaseUserHandler): status = 202 if user.stop_pending else 204 self.set_status(status) -class UserAdminAccessAPIHandler(BaseUserHandler): +class UserAdminAccessAPIHandler(APIHandler): """Grant admins access to single-user servers This handler sets the necessary cookie for an admin to login to a single-user server. @@ -184,6 +206,7 @@ class UserAdminAccessAPIHandler(BaseUserHandler): if not user.running: raise web.HTTPError(400, "%s's server is not running" % name) self.set_server_cookie(user) + current.other_user_cookies.add(name) default_handlers = [ diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 094e04e6..678a96c4 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -11,10 +11,12 @@ import os import signal import socket import sys +import threading from datetime import datetime from distutils.version import LooseVersion as V from getpass import getuser from subprocess import Popen +from urllib.parse import urlparse if sys.version_info[:2] < (3,3): raise ValueError("Python < 3.3 not supported: %s" % sys.version) @@ -31,26 +33,23 @@ from tornado.ioloop import IOLoop, PeriodicCallback from tornado.log import app_log, access_log, gen_log from tornado import gen, web -import IPython -if V(IPython.__version__) < V('3.0'): - raise ImportError("JupyterHub Requires IPython >= 3.0, found %s" % IPython.__version__) - -from IPython.utils.traitlets import ( +from traitlets import ( Unicode, Integer, Dict, TraitError, List, Bool, Any, Type, Set, Instance, Bytes, Float, ) -from IPython.config import Application, catch_config_error +from traitlets.config import Application, catch_config_error here = os.path.dirname(__file__) import jupyterhub from . import handlers, apihandlers -from .handlers.static import CacheControlStaticFilesHandler +from .handlers.static import CacheControlStaticFilesHandler, LogoHandler from . import orm +from .user import User, UserDict from ._data import DATA_FILES_PATH from .log import CoroutineLogFormatter, log_request -from .traitlets import URLPrefix +from .traitlets import URLPrefix, Command from .utils import ( url_path_join, ISO8601_ms, ISO8601_s, @@ -89,6 +88,9 @@ flags = { 'no-db': ({'JupyterHub': {'db_url': 'sqlite:///:memory:'}}, "disable persisting state database to disk" ), + 'no-ssl': ({'JupyterHub': {'confirm_no_ssl': True}}, + "Allow JupyterHub to run without SSL (SSL termination should be happening elsewhere)." + ), } SECRET_BYTES = 2048 # the number of bytes to use when generating new secrets @@ -126,6 +128,7 @@ class NewToken(Application): hub = JupyterHub(parent=self) hub.load_config_file(hub.config_file) hub.init_db() + hub.hub = hub.db.query(orm.Hub).first() hub.init_users() user = orm.User.find(hub.db, self.name) if user is None: @@ -138,6 +141,7 @@ class NewToken(Application): class JupyterHub(Application): """An Application for starting a Multi-User Jupyter Notebook server.""" name = 'jupyterhub' + version = jupyterhub.__version__ description = """Start a multi-user Jupyter Notebook server @@ -200,7 +204,20 @@ class JupyterHub(Application): data_files_path = Unicode(DATA_FILES_PATH, config=True, help="The location of jupyterhub data files (e.g. /usr/local/share/jupyter/hub)" ) + + template_paths = List( + config=True, + help="Paths to search for jinja templates.", + ) + + def _template_paths_default(self): + return [os.path.join(self.data_files_path, 'templates')] + confirm_no_ssl = Bool(False, config=True, + help="""Confirm that JupyterHub should be run without SSL. + This is **NOT RECOMMENDED** unless SSL termination is being handled by another layer. + """ + ) ssl_key = Unicode('', config=True, help="""Path to SSL key file for the public facing interface of the proxy @@ -214,20 +231,45 @@ class JupyterHub(Application): """ ) ip = Unicode('', config=True, - help="The public facing ip of the proxy" + help="The public facing ip of the whole application (the proxy)" ) + + subdomain_host = Unicode('', config=True, + help="""Run single-user servers on subdomains of this host. + + This should be the full https://hub.domain.tld[:port] + + Provides additional cross-site protections for javascript served by single-user servers. + + Requires .hub.domain.tld to resolve to the same host as hub.domain.tld. + + In general, this is most easily achieved with wildcard DNS. + + When using SSL (i.e. always) this also requires a wildcard SSL certificate. + """) + def _subdomain_host_changed(self, name, old, new): + if new and '://' not in new: + # host should include '://' + # if not specified, assume https: You have to be really explicit about HTTP! + self.subdomain_host = 'https://' + new + port = Integer(8000, config=True, help="The public facing port of the proxy" ) base_url = URLPrefix('/', config=True, help="The base URL of the entire application" ) + logo_file = Unicode('', config=True, + help="Specify path to a logo image to override the Jupyter logo in the banner." + ) + def _logo_file_default(self): + return os.path.join(self.data_files_path, 'static', 'images', 'jupyter.png') jinja_environment_options = Dict(config=True, help="Supply extra arguments that will be passed to Jinja environment." ) - proxy_cmd = Unicode('configurable-http-proxy', config=True, + proxy_cmd = Command('configurable-http-proxy', config=True, help="""The command to start the http proxy. Only override if configurable-http-proxy is not on your PATH @@ -252,7 +294,7 @@ class JupyterHub(Application): token = orm.new_token() return token - proxy_api_ip = Unicode('localhost', config=True, + proxy_api_ip = Unicode('127.0.0.1', config=True, help="The ip for the proxy API handlers" ) proxy_api_port = Integer(config=True, @@ -264,10 +306,9 @@ class JupyterHub(Application): hub_port = Integer(8081, config=True, help="The port for this process" ) - hub_ip = Unicode('localhost', config=True, + hub_ip = Unicode('127.0.0.1', config=True, help="The ip for this process" ) - hub_prefix = URLPrefix('/hub/', config=True, help="The prefix for the hub server. Must not be '/'" ) @@ -340,9 +381,13 @@ class JupyterHub(Application): debug_db = Bool(False, config=True, help="log all database transactions. This has A LOT of output" ) - db = Any() session_factory = Any() + users = Instance(UserDict) + def _users_default(self): + assert self.tornado_settings + return UserDict(db_factory=lambda : self.db, settings=self.tornado_settings) + admin_access = Bool(False, config=True, help="""Grant admin users permission to access single-user servers. @@ -350,11 +395,9 @@ class JupyterHub(Application): """ ) admin_users = Set(config=True, - help="""set of usernames of admin users - - If unspecified, only the user that launches the server will be admin. - """ + help="""DEPRECATED, use Authenticator.admin_users instead.""" ) + tornado_settings = Dict(config=True) cleanup_servers = Bool(True, config=True, @@ -460,13 +503,14 @@ class JupyterHub(Application): def init_handlers(self): h = [] - h.extend(handlers.default_handlers) - h.extend(apihandlers.default_handlers) # load handlers from the authenticator h.extend(self.authenticator.get_handlers(self)) - + # set default handlers + h.extend(handlers.default_handlers) + h.extend(apihandlers.default_handlers) + + h.append((r'/logo', LogoHandler, {'path': self.logo_file})) self.handlers = self.add_url_prefix(self.hub_prefix, h) - # some extra handlers, outside hub_prefix self.handlers.extend([ (r"%s" % self.hub_prefix.rstrip('/'), web.RedirectHandler, @@ -536,6 +580,44 @@ class JupyterHub(Application): # store the loaded trait value self.cookie_secret = secret + # thread-local storage of db objects + _local = Instance(threading.local, ()) + @property + def db(self): + if not hasattr(self._local, 'db'): + self._local.db = scoped_session(self.session_factory)() + return self._local.db + + @property + def hub(self): + if not getattr(self._local, 'hub', None): + q = self.db.query(orm.Hub) + assert q.count() <= 1 + self._local.hub = q.first() + if self.subdomain_host and self._local.hub: + self._local.hub.host = self.subdomain_host + return self._local.hub + + @hub.setter + def hub(self, hub): + self._local.hub = hub + if hub and self.subdomain_host: + hub.host = self.subdomain_host + + @property + def proxy(self): + if not getattr(self._local, 'proxy', None): + q = self.db.query(orm.Proxy) + assert q.count() <= 1 + p = self._local.proxy = q.first() + if p: + p.auth_token = self.proxy_auth_token + return self._local.proxy + + @proxy.setter + def proxy(self, proxy): + self._local.proxy = proxy + def init_db(self): """Create the database connection""" self.log.debug("Connecting to db: %s", self.db_url) @@ -546,7 +628,8 @@ class JupyterHub(Application): echo=self.debug_db, **self.db_kwargs ) - self.db = scoped_session(self.session_factory)() + # trigger constructing thread local db property + _ = self.db except OperationalError as e: self.log.error("Failed to connect to db: %s", self.db_url) self.log.debug("Database error was:", exc_info=True) @@ -572,6 +655,10 @@ class JupyterHub(Application): server.ip = self.hub_ip server.port = self.hub_port server.base_url = self.hub_prefix + if self.subdomain_host: + if not self.subdomain_host: + raise ValueError("Must specify subdomain_host when using subdomains." + " This should be the public domain[:port] of the Hub.") self.db.commit() @@ -579,16 +666,28 @@ class JupyterHub(Application): def init_users(self): """Load users into and from the database""" db = self.db - - if not self.admin_users: - # add current user as admin if there aren't any others - admins = db.query(orm.User).filter(orm.User.admin==True) - if admins.first() is None: - self.admin_users.add(getuser()) + + if self.admin_users and not self.authenticator.admin_users: + self.log.warn( + "\nJupyterHub.admin_users is deprecated." + "\nUse Authenticator.admin_users instead." + ) + self.authenticator.admin_users = self.admin_users + admin_users = [ + self.authenticator.normalize_username(name) + for name in self.authenticator.admin_users + ] + for username in admin_users: + if not self.authenticator.validate_username(username): + raise ValueError("username %r is not valid" % username) + + if not admin_users: + self.log.warning("No admin users, admin interface will be unavailable.") + self.log.warning("Add any administrative users to `c.Authenticator.admin_users` in config.") new_users = [] - for name in self.admin_users: + for name in admin_users: # ensure anyone specified as admin in config is admin in db user = orm.User.find(db, name) if user is None: @@ -601,7 +700,13 @@ class JupyterHub(Application): # the admin_users config variable will never be used after this point. # only the database values will be referenced. - whitelist = self.authenticator.whitelist + whitelist = [ + self.authenticator.normalize_username(name) + for name in self.authenticator.whitelist + ] + for username in whitelist: + if not self.authenticator.validate_username(username): + raise ValueError("username %r is not valid" % username) if not whitelist: self.log.info("Not using whitelist. Any authenticated user will be allowed.") @@ -621,7 +726,7 @@ class JupyterHub(Application): # but changes to the whitelist can occur in the database, # and persist across sessions. for user in db.query(orm.User): - whitelist.add(user.name) + self.authenticator.whitelist.add(user.name) # The whitelist set and the users in the db are now the same. # From this point on, any user changes should be done simultaneously @@ -632,6 +737,10 @@ class JupyterHub(Application): for user in new_users: yield gen.maybe_future(self.authenticator.add_user(user)) db.commit() + + @gen.coroutine + def init_spawners(self): + db = self.db user_summaries = [''] def _user_summary(user): @@ -651,16 +760,15 @@ class JupyterHub(Application): yield self.proxy.delete_user(user) yield user.stop() - for user in db.query(orm.User): + for orm_user in db.query(orm.User): + self.users[orm_user.id] = user = User(orm_user, self.tornado_settings) if not user.state: # without spawner state, server isn't valid user.server = None user_summaries.append(_user_summary(user)) continue self.log.debug("Loading state for %s from db", user.name) - user.spawner = spawner = self.spawner_class( - user=user, hub=self.hub, config=self.config, db=self.db, - ) + spawner = user.spawner status = yield spawner.poll() if status is None: self.log.info("%s still running", user.name) @@ -722,19 +830,33 @@ class JupyterHub(Application): env = os.environ.copy() env['CONFIGPROXY_AUTH_TOKEN'] = self.proxy.auth_token - cmd = [self.proxy_cmd, + cmd = self.proxy_cmd + [ '--ip', self.proxy.public_server.ip, '--port', str(self.proxy.public_server.port), '--api-ip', self.proxy.api_server.ip, '--api-port', str(self.proxy.api_server.port), '--default-target', self.hub.server.host, ] + if self.subdomain_host: + cmd.append('--host-routing') if self.debug_proxy: cmd.extend(['--log-level', 'debug']) if self.ssl_key: cmd.extend(['--ssl-key', self.ssl_key]) if self.ssl_cert: cmd.extend(['--ssl-cert', self.ssl_cert]) + # Require SSL to be used or `--no-ssl` to confirm no SSL on + if ' --ssl' not in ' '.join(cmd): + if self.confirm_no_ssl: + self.log.warning("Running JupyterHub without SSL." + " There better be SSL termination happening somewhere else...") + else: + self.log.error( + "Refusing to run JuptyterHub without SSL." + " If you are terminating SSL in another layer," + " pass --no-ssl to tell JupyterHub to allow the proxy to listen on HTTP." + ) + self.exit(1) self.log.info("Starting proxy @ %s", self.proxy.public_server.bind_url) self.log.debug("Proxy cmd: %s", cmd) try: @@ -775,16 +897,19 @@ class JupyterHub(Application): ) yield self.start_proxy() self.log.info("Setting up routes on new proxy") - yield self.proxy.add_all_users() + yield self.proxy.add_all_users(self.users) self.log.info("New proxy back up, and good to go") def init_tornado_settings(self): """Set up the tornado settings dict.""" base_url = self.hub.server.base_url - template_path = os.path.join(self.data_files_path, 'templates'), + jinja_options = dict( + autoescape=True, + ) + jinja_options.update(self.jinja_environment_options) jinja_env = Environment( - loader=FileSystemLoader(template_path), - **self.jinja_environment_options + loader=FileSystemLoader(self.template_paths), + **jinja_options ) login_url = self.authenticator.login_url(base_url) @@ -798,6 +923,8 @@ class JupyterHub(Application): else: version_hash=datetime.now().strftime("%Y%m%d%H%M%S"), + subdomain_host = self.subdomain_host + domain = urlparse(subdomain_host).hostname settings = dict( log_function=log_request, config=self.config, @@ -805,7 +932,7 @@ class JupyterHub(Application): db=self.db, proxy=self.proxy, hub=self.hub, - admin_users=self.admin_users, + admin_users=self.authenticator.admin_users, admin_access=self.admin_access, authenticator=self.authenticator, spawner_class=self.spawner_class, @@ -817,13 +944,17 @@ class JupyterHub(Application): static_path=os.path.join(self.data_files_path, 'static'), static_url_prefix=url_path_join(self.hub.server.base_url, 'static/'), static_handler_class=CacheControlStaticFilesHandler, - template_path=template_path, + template_path=self.template_paths, jinja2_env=jinja_env, version_hash=version_hash, + subdomain_host=subdomain_host, + domain=domain, ) # allow configured settings to have priority settings.update(self.tornado_settings) self.tornado_settings = settings + # constructing users requires access to tornado_settings + self.tornado_settings['users'] = self.users def init_tornado_application(self): """Instantiate the tornado Application object""" @@ -860,8 +991,9 @@ class JupyterHub(Application): self.init_hub() self.init_proxy() yield self.init_users() - self.init_handlers() self.init_tornado_settings() + yield self.init_spawners() + self.init_handlers() self.init_tornado_application() @gen.coroutine @@ -872,7 +1004,7 @@ class JupyterHub(Application): if self.cleanup_servers: self.log.info("Cleaning up single-user servers...") # request (async) process termination - for user in self.db.query(orm.User): + for uid, user in self.users.items(): if user.spawner is not None: futures.append(user.stop()) else: @@ -953,7 +1085,7 @@ class JupyterHub(Application): user.last_activity = max(user.last_activity, dt) self.db.commit() - yield self.proxy.check_routes(routes) + yield self.proxy.check_routes(self.users, routes) @gen.coroutine def start(self): @@ -970,6 +1102,16 @@ class JupyterHub(Application): loop.stop() return + # start the webserver + self.http_server = tornado.httpserver.HTTPServer(self.tornado_application, xheaders=True) + try: + self.http_server.listen(self.hub_port, address=self.hub_ip) + except Exception: + self.log.error("Failed to bind hub to %s", self.hub.server.bind_url) + raise + else: + self.log.info("Hub API listening on %s", self.hub.server.bind_url) + # start the proxy try: yield self.start_proxy() @@ -978,7 +1120,7 @@ class JupyterHub(Application): self.exit(1) return - loop.add_callback(self.proxy.add_all_users) + loop.add_callback(self.proxy.add_all_users, self.users) if self.proxy_process: # only check / restart the proxy if we started it in the first place. @@ -991,18 +1133,12 @@ class JupyterHub(Application): pc = PeriodicCallback(self.update_last_activity, 1e3 * self.last_activity_interval) pc.start() - # start the webserver - self.http_server = tornado.httpserver.HTTPServer(self.tornado_application, xheaders=True) - try: - self.http_server.listen(self.hub_port, address=self.hub_ip) - except Exception: - self.log.error("Failed to bind hub to %s" % self.hub.server.bind_url) - raise - else: - self.log.info("Hub API listening on %s" % self.hub.server.bind_url) - + self.log.info("JupyterHub is now running at %s", self.proxy.public_server.url) # register cleanup on both TERM and INT atexit.register(self.atexit) + self.init_signal() + + def init_signal(self): signal.signal(signal.SIGTERM, self.sigterm) def sigterm(self, signum, frame): @@ -1027,7 +1163,10 @@ class JupyterHub(Application): if not self.io_loop: return if self.http_server: - self.io_loop.add_callback(self.http_server.stop) + if self.io_loop._running: + self.io_loop.add_callback(self.http_server.stop) + else: + self.http_server.stop() self.io_loop.add_callback(self.io_loop.stop) @gen.coroutine diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index 67ab8c51..afd84075 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -1,28 +1,40 @@ -"""Simple PAM authenticator""" +"""Base Authenticator class and the default PAM Authenticator""" # Copyright (c) IPython Development Team. # Distributed under the terms of the Modified BSD License. from grp import getgrnam +import pipes import pwd -from subprocess import check_call, check_output, CalledProcessError +import re +from shutil import which +import sys +from subprocess import Popen, PIPE, STDOUT from tornado import gen -import simplepam +import pamela -from IPython.config import LoggingConfigurable -from IPython.utils.traitlets import Bool, Set, Unicode, Any +from traitlets.config import LoggingConfigurable +from traitlets import Bool, Set, Unicode, Dict, Any from .handlers.login import LoginHandler from .utils import url_path_join +from .traitlets import Command class Authenticator(LoggingConfigurable): """A class for authentication. - The API is one method, `authenticate`, a tornado gen.coroutine. + The primary API is one method, `authenticate`, a tornado coroutine + for authenticating users. """ db = Any() + admin_users = Set(config=True, + help="""set of usernames of admin users + + If unspecified, only the user that launches the server will be admin. + """ + ) whitelist = Set(config=True, help="""Username whitelist. @@ -30,7 +42,100 @@ class Authenticator(LoggingConfigurable): If empty, allow any user to attempt login. """ ) - custom_html = Unicode('') + custom_html = Unicode('', + help="""HTML login form for custom handlers. + Override in form-based custom authenticators + that don't use username+password, + or need custom branding. + """ + ) + login_service = Unicode('', + help="""Name of the login service for external + login services (e.g. 'GitHub'). + """ + ) + + username_pattern = Unicode(config=True, + help="""Regular expression pattern for validating usernames. + + If not defined: allow any username. + """ + ) + def _username_pattern_changed(self, name, old, new): + if not new: + self.username_regex = None + self.username_regex = re.compile(new) + + username_regex = Any() + + def validate_username(self, username): + """Validate a (normalized) username. + + Return True if username is valid, False otherwise. + """ + if not self.username_regex: + return True + return bool(self.username_regex.match(username)) + + username_map = Dict(config=True, + help="""Dictionary mapping authenticator usernames to JupyterHub users. + + Can be used to map OAuth service names to local users, for instance. + + Used in normalize_username. + """ + ) + + def normalize_username(self, username): + """Normalize a username. + + Override in subclasses if usernames should have some normalization. + Default: cast to lowercase, lookup in username_map. + """ + username = username.lower() + username = self.username_map.get(username, username) + return username + + def check_whitelist(self, username): + """Check a username against our whitelist. + + Return True if username is allowed, False otherwise. + No whitelist means any username should be allowed. + + Names are normalized *before* being checked against the whitelist. + """ + if not self.whitelist: + # No whitelist means any name is allowed + return True + return username in self.whitelist + + @gen.coroutine + def get_authenticated_user(self, handler, data): + """This is the outer API for authenticating a user. + + This calls `authenticate`, which should be overridden in subclasses, + normalizes the username if any normalization should be done, + and then validates the name in the whitelist. + + Subclasses should not need to override this method. + The various stages can be overridden separately: + + - authenticate turns formdata into a username + - normalize_username normalizes the username + - check_whitelist checks against the user whitelist + """ + username = yield self.authenticate(handler, data) + if username is None: + return + username = self.normalize_username(username) + if not self.validate_username(username): + self.log.warning("Disallowing invalid username %r.", username) + return + if self.check_whitelist(username): + return username + else: + self.log.warning("User %r not in whitelist.", username) + return @gen.coroutine def authenticate(self, handler, data): @@ -39,23 +144,44 @@ class Authenticator(LoggingConfigurable): This must be a tornado gen.coroutine. It must return the username on successful authentication, and return None on failed authentication. + + Checking the whitelist is handled separately by the caller. + + Args: + handler (tornado.web.RequestHandler): the current request handler + data (dict): The formdata of the login form. + The default form has 'username' and 'password' fields. + Return: + str: the username of the authenticated user + None: Authentication failed """ - def check_whitelist(self, user): + def pre_spawn_start(self, user, spawner): + """Hook called before spawning a user's server. + + Can be used to do auth-related startup, e.g. opening PAM sessions. """ - Return True if the whitelist is empty or user is in the whitelist. + + def post_spawn_stop(self, user, spawner): + """Hook called after stopping a user container. + + Can be used to do auth-related cleanup, e.g. closing PAM sessions. """ - # Parens aren't necessary here, but they make this easier to parse. - return (not self.whitelist) or (user in self.whitelist) - + def add_user(self, user): """Add a new user By default, this just adds the user to the whitelist. Subclasses may do more extensive things, - such as adding actual unix users. + such as adding actual unix users, + but they should call super to ensure the whitelist is updated. + + Args: + user (User): The User wrapper object """ + if not self.validate_username(user.name): + raise ValueError("Invalid username: %s" % user.name) if self.whitelist: self.whitelist.add(user.name) @@ -63,29 +189,60 @@ class Authenticator(LoggingConfigurable): """Triggered when a user is deleted. Removes the user from the whitelist. + Subclasses should call super to ensure the whitelist is updated. + + Args: + user (User): The User wrapper object """ self.whitelist.discard(user.name) def login_url(self, base_url): - """Override to register a custom login handler""" + """Override to register a custom login handler + + Generally used in combination with get_handlers. + + Args: + base_url (str): the base URL of the Hub (e.g. /hub/) + + Returns: + str: The login URL, e.g. '/hub/login' + + """ return url_path_join(base_url, 'login') def logout_url(self, base_url): - """Override to register a custom logout handler""" + """Override to register a custom logout handler. + + Generally used in combination with get_handlers. + + Args: + base_url (str): the base URL of the Hub (e.g. /hub/) + + Returns: + str: The logout URL, e.g. '/hub/logout' + """ return url_path_join(base_url, 'logout') def get_handlers(self, app): """Return any custom handlers the authenticator needs to register - (e.g. for OAuth) + (e.g. for OAuth). + + Args: + app (JupyterHub Application): + the application object, in case it needs to be accessed for info. + Returns: + list: list of ``('/url', Handler)`` tuples passed to tornado. + The Hub prefix is added to any URLs. + """ return [ ('/login', LoginHandler), ] class LocalAuthenticator(Authenticator): - """Base class for Authenticators that work with local *ix users - + """Base class for Authenticators that work with local Linux/UNIX users + Checks for local users, and can attempt to create them if they exist. """ @@ -94,6 +251,36 @@ class LocalAuthenticator(Authenticator): should I try to create the system user? """ ) + add_user_cmd = Command(config=True, + help="""The command to use for creating users as a list of strings. + + For each element in the list, the string USERNAME will be replaced with + the user's username. The username will also be appended as the final argument. + + For Linux, the default value is: + + ['adduser', '-q', '--gecos', '""', '--disabled-password'] + + To specify a custom home directory, set this to: + + ['adduser', '-q', '--gecos', '""', '--home', '/customhome/USERNAME', '--disabled-password'] + + This will run the command: + + adduser -q --gecos "" --home /customhome/river --disabled-password river + + when the user 'river' is created. + """ + ) + def _add_user_cmd_default(self): + if sys.platform == 'darwin': + raise ValueError("I don't know how to create users on OS X") + elif which('pw'): + # Probably BSD + return ['pw', 'useradd', '-m'] + else: + # This appears to be the Linux non-interactive adduser command: + return ['adduser', '-q', '--gecos', '""', '--disabled-password'] group_whitelist = Set( config=True, @@ -115,11 +302,11 @@ class LocalAuthenticator(Authenticator): def check_group_whitelist(self, username): if not self.group_whitelist: return False - for group in self.group_whitelist: + for grnam in self.group_whitelist: try: - group = getgrnam(self.group_whitelist) + group = getgrnam(grnam) except KeyError: - self.log.error('No such group: [%s]' % self.group_whitelist) + self.log.error('No such group: [%s]' % grnam) continue if username in group.gr_mem: return True @@ -129,10 +316,7 @@ class LocalAuthenticator(Authenticator): def add_user(self, user): """Add a new user - By default, this just adds the user to the whitelist. - - Subclasses may do more extensive things, - such as adding actual unix users. + If self.create_system_users, the user will attempt to be created. """ user_exists = yield gen.maybe_future(self.system_user_exists(user)) if not user_exists: @@ -152,29 +336,21 @@ class LocalAuthenticator(Authenticator): return False else: return True - - @staticmethod - def add_system_user(user): - """Create a new *ix user on the system. Works on FreeBSD and Linux, at least.""" + + def add_system_user(self, user): + """Create a new Linux/UNIX user on the system. Works on FreeBSD and Linux, at least.""" name = user.name - for useradd in ( - ['pw', 'useradd', '-m'], - ['useradd', '-m'], - ): - try: - check_output(['which', useradd[0]]) - except CalledProcessError: - continue - else: - break - else: - raise RuntimeError("I don't know how to add users on this system.") - - check_call(useradd + [name]) + cmd = [ arg.replace('USERNAME', name) for arg in self.add_user_cmd ] + [name] + self.log.info("Creating user: %s", ' '.join(map(pipes.quote, cmd))) + p = Popen(cmd, stdout=PIPE, stderr=STDOUT) + p.wait() + if p.returncode: + err = p.stdout.read().decode('utf8', 'replace') + raise RuntimeError("Failed to create system user %s: %s" % (name, err)) class PAMAuthenticator(LocalAuthenticator): - """Authenticate local *ix users with PAM""" + """Authenticate local Linux/UNIX users with PAM""" encoding = Unicode('utf8', config=True, help="""The encoding to use for PAM""" ) @@ -189,12 +365,27 @@ class PAMAuthenticator(LocalAuthenticator): Return None otherwise. """ username = data['username'] - if not self.check_whitelist(username): - return - # simplepam wants bytes, not unicode - # see simplepam#3 - busername = username.encode(self.encoding) - bpassword = data['password'].encode(self.encoding) - if simplepam.authenticate(busername, bpassword, service=self.service): + try: + pamela.authenticate(username, data['password'], service=self.service) + except pamela.PAMError as e: + if handler is not None: + self.log.warn("PAM Authentication failed (@%s): %s", handler.request.remote_ip, e) + else: + self.log.warn("PAM Authentication failed: %s", e) + else: return username + def pre_spawn_start(self, user, spawner): + """Open PAM session for user""" + try: + pamela.open_session(user.name, service=self.service) + except pamela.PAMError as e: + self.log.warn("Failed to open PAM session for %s: %s", user.name, e) + + def post_spawn_stop(self, user, spawner): + """Close PAM session for user""" + try: + pamela.close_session(user.name, service=self.service) + except pamela.PAMError as e: + self.log.warn("Failed to close PAM session for %s: %s", user.name, e) + diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 33e2d76d..b7977ea4 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -4,7 +4,7 @@ # Distributed under the terms of the Modified BSD License. import re -from datetime import datetime, timedelta +from datetime import timedelta from http.client import responses from jinja2 import TemplateNotFound @@ -16,6 +16,7 @@ from tornado.web import RequestHandler from tornado import gen, web from .. import orm +from ..user import User from ..spawner import LocalProcessSpawner from ..utils import url_path_join @@ -50,10 +51,22 @@ class BaseHandler(RequestHandler): def version_hash(self): return self.settings.get('version_hash', '') + @property + def subdomain_host(self): + return self.settings.get('subdomain_host', '') + + @property + def domain(self): + return self.settings['domain'] + @property def db(self): return self.settings['db'] - + + @property + def users(self): + return self.settings.setdefault('users', {}) + @property def hub(self): return self.settings['hub'] @@ -69,7 +82,40 @@ class BaseHandler(RequestHandler): def finish(self, *args, **kwargs): """Roll back any uncommitted transactions from the handler.""" self.db.rollback() - super(BaseHandler, self).finish(*args, **kwargs) + super().finish(*args, **kwargs) + + #--------------------------------------------------------------- + # Security policies + #--------------------------------------------------------------- + + @property + def csp_report_uri(self): + return self.settings.get('csp_report_uri', + url_path_join(self.hub.server.base_url, 'security/csp-report') + ) + + @property + def content_security_policy(self): + """The default Content-Security-Policy header + + Can be overridden by defining Content-Security-Policy in settings['headers'] + """ + return '; '.join([ + "frame-ancestors 'self'", + "report-uri " + self.csp_report_uri, + ]) + + def set_default_headers(self): + """ + Set any headers passed as tornado_settings['headers']. + + By default sets Content-Security-Policy of frame-ancestors 'self'. + """ + headers = self.settings.get('headers', {}) + headers.setdefault("Content-Security-Policy", self.content_security_policy) + + for header_name, header_content in headers.items(): + self.set_header(header_name, header_content) #--------------------------------------------------------------- # Login and cookie-related @@ -112,13 +158,20 @@ class BaseHandler(RequestHandler): clear() return cookie_id = cookie_id.decode('utf8', 'replace') - user = self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first() + u = self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first() + user = self._user_from_orm(u) if user is None: self.log.warn("Invalid cookie token") # have cookie, but it's not valid. Clear it and start over. clear() return user + def _user_from_orm(self, orm_user): + """return User wrapper from orm.User object""" + if orm_user is None: + return + return self.users[orm_user] + def get_current_user_cookie(self): """get_current_user from a cookie token""" return self._user_for_cookie(self.hub.server.cookie_name) @@ -135,40 +188,63 @@ class BaseHandler(RequestHandler): return None if no such user """ - return orm.User.find(self.db, name) + orm_user = orm.User.find(db=self.db, name=name) + return self._user_from_orm(orm_user) def user_from_username(self, username): - """Get ORM User for username""" + """Get User for username, creating if it doesn't exist""" user = self.find_user(username) if user is None: - user = orm.User(name=username) - self.db.add(user) + # not found, create and register user + u = orm.User(name=username) + self.db.add(u) self.db.commit() + user = self._user_from_orm(u) return user - def clear_login_cookie(self): - user = self.get_current_user() + def clear_login_cookie(self, name=None): + if name is None: + user = self.get_current_user() + else: + user = self.find_user(name) + kwargs = {} + if self.subdomain_host: + kwargs['domain'] = self.domain if user and user.server: - self.clear_cookie(user.server.cookie_name, path=user.server.base_url) - self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url) + self.clear_cookie(user.server.cookie_name, path=user.server.base_url, **kwargs) + self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url, **kwargs) + + def _set_user_cookie(self, user, server): + # tornado <4.2 have a bug that consider secure==True as soon as + # 'secure' kwarg is passed to set_secure_cookie + if self.request.protocol == 'https': + kwargs = {'secure': True} + else: + kwargs = {} + if self.subdomain_host: + kwargs['domain'] = self.domain + self.log.debug("Setting cookie for %s: %s, %s", user.name, server.cookie_name, kwargs) + self.set_secure_cookie( + server.cookie_name, + user.cookie_id, + path=server.base_url, + **kwargs + ) def set_server_cookie(self, user): """set the login cookie for the single-user server""" - self.set_secure_cookie( - user.server.cookie_name, - user.cookie_id, - path=user.server.base_url, - ) + self._set_user_cookie(user, user.server) def set_hub_cookie(self, user): """set the login cookie for the Hub""" - self.set_secure_cookie( - self.hub.server.cookie_name, - user.cookie_id, - path=self.hub.server.base_url) + self._set_user_cookie(user, self.hub.server) def set_login_cookie(self, user): """Set login cookies for the Hub and single-user server.""" + if self.subdomain_host and not self.request.host.startswith(self.domain): + self.log.warning( + "Possibly setting cookie on wrong domain: %s != %s", + self.request.host, self.domain) # create and set a new cookie token for the single-user server if user.server: self.set_server_cookie(user) @@ -181,7 +257,7 @@ class BaseHandler(RequestHandler): def authenticate(self, data): auth = self.authenticator if auth is not None: - result = yield auth.authenticate(self, data) + result = yield auth.get_authenticated_user(self, data) return result else: self.log.error("No authentication function, login is impossible!") @@ -202,19 +278,15 @@ class BaseHandler(RequestHandler): @property def spawner_class(self): return self.settings.get('spawner_class', LocalProcessSpawner) - + @gen.coroutine - def spawn_single_user(self, user): + def spawn_single_user(self, user, options=None): if user.spawn_pending: raise RuntimeError("Spawn already pending for: %s" % user.name) tic = IOLoop.current().time() - - f = user.spawn( - spawner_class=self.spawner_class, - base_url=self.base_url, - hub=self.hub, - config=self.config, - ) + + f = user.spawn(options) + @gen.coroutine def finish_user_spawn(f=None): """Finish the user spawn by registering listeners and notifying the proxy. @@ -234,12 +306,23 @@ class BaseHandler(RequestHandler): yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), f) except gen.TimeoutError: if user.spawn_pending: - # hit timeout, but spawn is still pending + # still in Spawner.start, which is taking a long time + # we shouldn't poll while spawn_pending is True self.log.warn("User %s server is slow to start", user.name) # schedule finish for when the user finishes spawning IOLoop.current().add_future(f, finish_user_spawn) else: - raise + # start has finished, but the server hasn't come up + # check if the server died while we were waiting + status = yield user.spawner.poll() + if status is None: + # hit timeout, but server's running. Hope that it'll show up soon enough, + # though it's possible that it started at the wrong URL + self.log.warn("User %s server is slow to become responsive", user.name) + # schedule finish for when the user finishes spawning + IOLoop.current().add_future(f, finish_user_spawn) + else: + raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status) else: yield finish_user_spawn() @@ -309,6 +392,7 @@ class BaseHandler(RequestHandler): prefix=self.base_url, user=user, login_url=self.settings['login_url'], + login_service=self.authenticator.login_service, logout_url=self.settings['logout_url'], static_url=self.static_url, version_hash=self.version_hash, @@ -363,48 +447,71 @@ class PrefixRedirectHandler(BaseHandler): Redirects /foo to /prefix/foo, etc. """ def get(self): - path = self.request.path[len(self.base_url):] + path = self.request.uri[len(self.base_url):] self.redirect(url_path_join( self.hub.server.base_url, path, ), permanent=False) + class UserSpawnHandler(BaseHandler): - """Requests to /user/name handled by the Hub - should result in spawning the single-user server and - being redirected to the original. + """Redirect requests to /user/name/* handled by the Hub. + + If logged in, spawn a single-user server and redirect request. + If a user, alice, requests /user/bob/notebooks/mynotebook.ipynb, + redirect her to /user/alice/notebooks/mynotebook.ipynb, which should + in turn call this function. """ + @gen.coroutine - def get(self, name): + def get(self, name, user_path): current_user = self.get_current_user() if current_user and current_user.name == name: - # logged in, spawn the server + # logged in as correct user, spawn the server if current_user.spawner: if current_user.spawn_pending: # spawn has started, but not finished html = self.render_template("spawn_pending.html", user=current_user) self.finish(html) return - + # spawn has supposedly finished, check on the status status = yield current_user.spawner.poll() if status is not None: - yield self.spawn_single_user(current_user) - else: - yield self.spawn_single_user(current_user) + if current_user.spawner.options_form: + self.redirect(url_path_join(self.hub.server.base_url, 'spawn')) + return + else: + yield self.spawn_single_user(current_user) # set login cookie anew self.set_login_cookie(current_user) - without_prefix = self.request.path[len(self.hub.server.base_url):] + without_prefix = self.request.uri[len(self.hub.server.base_url):] target = url_path_join(self.base_url, without_prefix) + if self.subdomain_host: + target = current_user.host + target + self.redirect(target) + elif current_user: + # logged in as a different user, redirect + target = url_path_join(self.base_url, 'user', current_user.name, + user_path or '') self.redirect(target) else: - # not logged in to the right user, - # clear any cookies and reload (will redirect to login) + # not logged in, clear any cookies and reload self.clear_login_cookie() self.redirect(url_concat( self.settings['login_url'], - {'next': self.request.path, - })) + {'next': self.request.uri}, + )) + + +class CSPReportHandler(BaseHandler): + '''Accepts a content security policy violation report''' + @web.authenticated + def post(self): + '''Log a content security policy violation report''' + self.log.warn("Content security violation: %s", + self.request.body.decode('utf8', 'replace')) default_handlers = [ - (r'/user/([^/]+)/?.*', UserSpawnHandler), + (r'/user/([^/]+)(/.*)?', UserSpawnHandler), + (r'/security/csp-report', CSPReportHandler), ] diff --git a/jupyterhub/handlers/login.py b/jupyterhub/handlers/login.py index 95bc314d..1336c789 100644 --- a/jupyterhub/handlers/login.py +++ b/jupyterhub/handlers/login.py @@ -12,31 +12,44 @@ from .base import BaseHandler class LogoutHandler(BaseHandler): """Log a user out by clearing their login cookie.""" def get(self): + user = self.get_current_user() + if user: + self.log.info("User logged out: %s", user.name) self.clear_login_cookie() - html = self.render_template('logout.html') - self.finish(html) + for name in user.other_user_cookies: + self.clear_login_cookie(name) + user.other_user_cookies = set([]) + self.redirect(self.hub.server.base_url, permanent=False) class LoginHandler(BaseHandler): """Render the login page.""" - def _render(self, message=None, username=None): + def _render(self, login_error=None, username=None): return self.render_template('login.html', next=url_escape(self.get_argument('next', default='')), username=username, - message=message, + login_error=login_error, custom_html=self.authenticator.custom_html, + login_url=self.settings['login_url'], ) - + def get(self): - next_url = self.get_argument('next', False) - if next_url and self.get_current_user(): + next_url = self.get_argument('next', '') + if not next_url.startswith('/'): + # disallow non-absolute next URLs (e.g. full URLs) + next_url = '' + user = self.get_current_user() + if user: + if not next_url: + if user.running: + next_url = user.server.base_url + else: + next_url = self.hub.server.base_url # set new login cookie # because single-user cookie may have been cleared or incorrect self.set_login_cookie(self.get_current_user()) self.redirect(next_url, permanent=False) - elif not next_url and self.get_current_user(): - self.redirect(self.hub.server.base_url, permanent=False) else: username = self.get_argument('username', default='') self.finish(self._render(username=username)) @@ -48,29 +61,35 @@ class LoginHandler(BaseHandler): for arg in self.request.arguments: data[arg] = self.get_argument(arg) - username = data['username'] - authorized = yield self.authenticate(data) - if authorized: + username = yield self.authenticate(data) + if username: user = self.user_from_username(username) already_running = False if user.spawner: status = yield user.spawner.poll() already_running = (status == None) - if not already_running: + if not already_running and not user.spawner.options_form: yield self.spawn_single_user(user) self.set_login_cookie(user) - next_url = self.get_argument('next', default='') or self.hub.server.base_url + next_url = self.get_argument('next', default='') + if not next_url.startswith('/'): + next_url = '' + next_url = next_url or self.hub.server.base_url self.redirect(next_url) + self.log.info("User logged in: %s", username) else: self.log.debug("Failed login for %s", username) html = self._render( - message={'error': 'Invalid username or password'}, + login_error='Invalid username or password', username=username, ) self.finish(html) -# Only logout is a default handler. +# /login renders the login page or the "Login with..." link, +# so it should always be registered. +# /logout clears cookies. default_handlers = [ + (r"/login", LoginHandler), (r"/logout", LogoutHandler), ] diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index 72ee223c..e82490c4 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -3,31 +3,38 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -from tornado import web +from tornado import web, gen from .. import orm from ..utils import admin_only, url_path_join from .base import BaseHandler +from .login import LoginHandler class RootHandler(BaseHandler): """Render the Hub root page. - Currently redirects to home if logged in, - shows big fat login button otherwise. + If logged in, redirects to: + + - single-user server if running + - hub home, otherwise + + Otherwise, renders login page. """ def get(self): - if self.get_current_user(): - self.redirect( - url_path_join(self.hub.server.base_url, 'home'), - permanent=False, - ) + user = self.get_current_user() + if user: + if user.running: + url = user.url + self.log.debug("User is running: %s", url) + else: + url = url_path_join(self.hub.server.base_url, 'home') + self.log.debug("User is not running: %s", url) + self.redirect(url) return - - html = self.render_template('index.html', - login_url=self.settings['login_url'], - ) - self.finish(html) + url = url_path_join(self.hub.server.base_url, 'login') + self.redirect(url) + class HomeHandler(BaseHandler): """Render the user's home page.""" @@ -40,6 +47,63 @@ class HomeHandler(BaseHandler): self.finish(html) +class SpawnHandler(BaseHandler): + """Handle spawning of single-user servers via form. + + GET renders the form, POST handles form submission. + + Only enabled when Spawner.options_form is defined. + """ + def _render_form(self, message=''): + user = self.get_current_user() + return self.render_template('spawn.html', + user=user, + spawner_options_form=user.spawner.options_form, + error_message=message, + ) + + @web.authenticated + def get(self): + """GET renders form for spawning with user-specified options""" + user = self.get_current_user() + if user.running: + url = user.url + self.log.debug("User is running: %s", url) + self.redirect(url) + return + if user.spawner.options_form: + self.finish(self._render_form()) + else: + # not running, no form. Trigger spawn. + url = url_path_join(self.base_url, 'user', user.name) + self.redirect(url) + + @web.authenticated + @gen.coroutine + def post(self): + """POST spawns with user-specified options""" + user = self.get_current_user() + if user.running: + url = user.url + self.log.warning("User is already running: %s", url) + self.redirect(url) + return + form_options = {} + for key, byte_list in self.request.body_arguments.items(): + form_options[key] = [ bs.decode('utf8') for bs in byte_list ] + for key, byte_list in self.request.files.items(): + form_options["%s_file"%key] = byte_list + try: + options = user.spawner.options_from_form(form_options) + yield self.spawn_single_user(user, options=options) + except Exception as e: + self.log.error("Failed to spawn single-user server with form", exc_info=True) + self.finish(self._render_form(str(e))) + return + self.set_login_cookie(user) + url = user.url + self.redirect(url) + class AdminHandler(BaseHandler): """Render the admin page.""" @@ -83,7 +147,8 @@ class AdminHandler(BaseHandler): ordered = [ getattr(c, o)() for c, o in zip(cols, orders) ] users = self.db.query(orm.User).order_by(*ordered) - running = users.filter(orm.User.server != None) + users = [ self._user_from_orm(u) for u in users ] + running = [ u for u in users if u.running ] html = self.render_template('admin.html', user=self.get_current_user(), @@ -99,4 +164,5 @@ default_handlers = [ (r'/', RootHandler), (r'/home', HomeHandler), (r'/admin', AdminHandler), + (r'/spawn', SpawnHandler), ] diff --git a/jupyterhub/handlers/static.py b/jupyterhub/handlers/static.py index 7e972981..9ed9e541 100644 --- a/jupyterhub/handlers/static.py +++ b/jupyterhub/handlers/static.py @@ -1,6 +1,7 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +import os from tornado.web import StaticFileHandler class CacheControlStaticFilesHandler(StaticFileHandler): @@ -14,4 +15,14 @@ class CacheControlStaticFilesHandler(StaticFileHandler): def set_extra_headers(self, path): if "v" not in self.request.arguments: self.add_header("Cache-Control", "no-cache") - \ No newline at end of file + +class LogoHandler(StaticFileHandler): + """A singular handler for serving the logo.""" + def get(self): + return super().get('') + + @classmethod + def get_absolute_path(cls, root, path): + """We only serve one file, ignore relative path""" + return os.path.abspath(root) + diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index a24dd9b6..0f1fb437 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -3,14 +3,14 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -from datetime import datetime, timedelta +from datetime import datetime import errno import json import socket from tornado import gen from tornado.log import app_log -from tornado.httpclient import HTTPRequest, AsyncHTTPClient, HTTPError +from tornado.httpclient import HTTPRequest, AsyncHTTPClient from sqlalchemy.types import TypeDecorator, VARCHAR from sqlalchemy import ( @@ -78,7 +78,7 @@ class Server(Base): ip = self.ip if ip in {'', '0.0.0.0'}: # when listening on all interfaces, connect to localhost - ip = 'localhost' + ip = '127.0.0.1' return "{proto}://{ip}:{port}".format( proto=self.proto, ip=ip, @@ -100,7 +100,7 @@ class Server(Base): since it can be non-connectable value, such as '', meaning all interfaces. """ if self.ip in {'', '0.0.0.0'}: - return self.url.replace('localhost', self.ip or '*', 1) + return self.url.replace('127.0.0.1', self.ip or '*', 1) return self.url @gen.coroutine @@ -109,14 +109,24 @@ class Server(Base): if http: yield wait_for_http_server(self.url, timeout=timeout) else: - yield wait_for_server(self.ip or 'localhost', self.port, timeout=timeout) + yield wait_for_server(self.ip or '127.0.0.1', self.port, timeout=timeout) def is_up(self): """Is the server accepting connections?""" try: - socket.create_connection((self.ip or 'localhost', self.port)) + socket.create_connection((self.ip or '127.0.0.1', self.port)) except socket.error as e: - if e.errno == errno.ECONNREFUSED: + if e.errno == errno.ENETUNREACH: + try: + socket.create_connection((self.ip or '127.0.0.1', self.port)) + except socket.error as e: + if e.errno == errno.ECONNREFUSED: + return False + else: + raise + else: + return True + elif e.errno == errno.ECONNREFUSED: return False else: raise @@ -145,7 +155,7 @@ class Proxy(Base): ) else: return "<%s [unconfigured]>" % self.__class__.__name__ - + def api_request(self, path, method='GET', body=None, client=None): """Make an authenticated API request of the proxy""" client = client or AsyncHTTPClient() @@ -166,10 +176,13 @@ class Proxy(Base): def add_user(self, user, client=None): """Add a user's server to the proxy table.""" self.log.info("Adding user %s to proxy %s => %s", - user.name, user.server.base_url, user.server.host, + user.name, user.proxy_path, user.server.host, ) + if user.spawn_pending: + raise RuntimeError( + "User %s's spawn is pending, shouldn't be added to the proxy yet!", user.name) - yield self.api_request(user.server.base_url, + yield self.api_request(user.proxy_path, method='POST', body=dict( target=user.server.host, @@ -182,26 +195,11 @@ class Proxy(Base): def delete_user(self, user, client=None): """Remove a user's server to the proxy table.""" self.log.info("Removing user %s from proxy", user.name) - yield self.api_request(user.server.base_url, + yield self.api_request(user.proxy_path, method='DELETE', client=client, ) - @gen.coroutine - def add_all_users(self): - """Update the proxy table from the database. - - Used when loading up a new proxy. - """ - db = inspect(self).session - futures = [] - for user in db.query(User): - if (user.server): - futures.append(self.add_user(user)) - # wait after submitting them all - for f in futures: - yield f - @gen.coroutine def get_routes(self, client=None): """Fetch the proxy's routes""" @@ -209,17 +207,42 @@ class Proxy(Base): return json.loads(resp.body.decode('utf8', 'replace')) @gen.coroutine - def check_routes(self, routes=None): - """Check that all users are properly""" + def add_all_users(self, user_dict): + """Update the proxy table from the database. + + Used when loading up a new proxy. + """ + db = inspect(self).session + futures = [] + for orm_user in db.query(User): + user = user_dict[orm_user] + if user.running: + futures.append(self.add_user(user)) + # wait after submitting them all + for f in futures: + yield f + + @gen.coroutine + def check_routes(self, user_dict, routes=None): + """Check that all users are properly routed on the proxy""" if not routes: routes = yield self.get_routes() have_routes = { r['user'] for r in routes.values() if 'user' in r } futures = [] db = inspect(self).session - for user in db.query(User).filter(User.server != None): + for orm_user in db.query(User).filter(User.server != None): + user = user_dict[orm_user] + if not user.running: + # Don't add users to the proxy that haven't finished starting + continue + if user.server is None: + # This should never be True, but seems to be on rare occasion. + # catch filter bug, either in sqlalchemy or my understanding of its behavior + self.log.error("User %s has no server, but wasn't filtered out.", user) + continue if user.name not in have_routes: - self.log.warn("Adding missing route for %s", user.name) + self.log.warning("Adding missing route for %s (%s)", user.name, user.server) futures.append(self.add_user(user)) for f in futures: yield f @@ -238,6 +261,7 @@ class Hub(Base): id = Column(Integer, primary_key=True) _server_id = Column(Integer, ForeignKey('servers.id')) server = relationship(Server, primaryjoin=_server_id == Server.id) + host = '' @property def api_url(self): @@ -270,7 +294,7 @@ class User(Base): used for restoring state of a Spawner. """ __tablename__ = 'users' - id = Column(Integer, primary_key=True) + id = Column(Integer, primary_key=True, autoincrement=True) name = Column(Unicode) # should we allow multiple servers per user? _server_id = Column(Integer, ForeignKey('servers.id')) @@ -281,10 +305,9 @@ class User(Base): api_tokens = relationship("APIToken", backref="user") cookie_id = Column(Unicode, default=new_token) state = Column(JSONDict) - spawner = None - spawn_pending = False - stop_pending = False - + + other_user_cookies = set([]) + def __repr__(self): if self.server: return "<{cls}({name}@{ip}:{port})>".format( @@ -299,15 +322,6 @@ class User(Base): name=self.name, ) - @property - def running(self): - """property for whether a user has a running server""" - if self.spawner is None: - return False - if self.server is None: - return False - return True - def new_api_token(self): """Create a new API token""" assert self.id is not None @@ -318,7 +332,7 @@ class User(Base): db.add(orm_token) db.commit() return token - + @classmethod def find(cls, db, name): """Find a user by name. @@ -326,115 +340,6 @@ class User(Base): Returns None if not found. """ return db.query(cls).filter(cls.name==name).first() - - @gen.coroutine - def spawn(self, spawner_class, base_url='/', hub=None, config=None): - """Start the user's spawner""" - db = inspect(self).session - if hub is None: - hub = db.query(Hub).first() - self.server = Server( - cookie_name='%s-%s' % (hub.server.cookie_name, self.name), - base_url=url_path_join(base_url, 'user', self.name), - ) - db.add(self.server) - db.commit() - - api_token = self.new_api_token() - db.commit() - - spawner = self.spawner = spawner_class( - config=config, - user=self, - hub=hub, - db=db, - ) - # we are starting a new server, make sure it doesn't restore state - spawner.clear_state() - spawner.api_token = api_token - - self.spawn_pending = True - # wait for spawner.start to return - try: - f = spawner.start() - yield gen.with_timeout(timedelta(seconds=spawner.start_timeout), f) - except Exception as e: - if isinstance(e, gen.TimeoutError): - self.log.warn("{user}'s server failed to start in {s} seconds, giving up".format( - user=self.name, s=spawner.start_timeout, - )) - e.reason = 'timeout' - else: - self.log.error("Unhandled error starting {user}'s server: {error}".format( - user=self.name, error=e, - )) - e.reason = 'error' - try: - yield self.stop() - except Exception: - self.log.error("Failed to cleanup {user}'s server that failed to start".format( - user=self.name, - ), exc_info=True) - # raise original exception - raise e - spawner.start_polling() - - # store state - self.state = spawner.get_state() - self.last_activity = datetime.utcnow() - db.commit() - try: - yield self.server.wait_up(http=True, timeout=spawner.http_timeout) - except Exception as e: - if isinstance(e, TimeoutError): - self.log.warn( - "{user}'s server never showed up at {url} " - "after {http_timeout} seconds. Giving up".format( - user=self.name, - url=self.server.url, - http_timeout=spawner.http_timeout, - ) - ) - e.reason = 'timeout' - else: - e.reason = 'error' - self.log.error("Unhandled error waiting for {user}'s server to show up at {url}: {error}".format( - user=self.name, url=self.server.url, error=e, - )) - try: - yield self.stop() - except Exception: - self.log.error("Failed to cleanup {user}'s server that failed to start".format( - user=self.name, - ), exc_info=True) - # raise original TimeoutError - raise e - self.spawn_pending = False - return self - - @gen.coroutine - def stop(self): - """Stop the user's spawner - - and cleanup after it. - """ - self.spawn_pending = False - if self.spawner is None: - return - self.spawner.stop_polling() - self.stop_pending = True - try: - status = yield self.spawner.poll() - if status is None: - yield self.spawner.stop() - self.spawner.clear_state() - self.state = self.spawner.get_state() - self.last_activity = datetime.utcnow() - self.server = None - inspect(self).session.commit() - finally: - self.stop_pending = False - class APIToken(Base): """An API token""" diff --git a/jupyterhub/singleuser.py b/jupyterhub/singleuser.py deleted file mode 100644 index 71ed67bc..00000000 --- a/jupyterhub/singleuser.py +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env python3 -"""Extend regular notebook server to be aware of multiuser things.""" - -# Copyright (c) Jupyter Development Team. -# Distributed under the terms of the Modified BSD License. - -import os -from urllib.parse import quote - -import requests - -from tornado import ioloop -from tornado.web import HTTPError - -from IPython.utils.traitlets import ( - Integer, - Unicode, - CUnicode, -) - -from IPython.html.notebookapp import NotebookApp -from IPython.html.auth.login import LoginHandler -from IPython.html.auth.logout import LogoutHandler - -from IPython.html.utils import url_path_join - - -from distutils.version import LooseVersion as V - -import IPython -if V(IPython.__version__) < V('3.0'): - raise ImportError("JupyterHub Requires IPython >= 3.0, found %s" % IPython.__version__) - -# Define two methods to attach to AuthenticatedHandler, -# which authenticate via the central auth server. - -class JupyterHubLoginHandler(LoginHandler): - @staticmethod - def login_available(settings): - return True - - @staticmethod - def verify_token(self, cookie_name, encrypted_cookie): - """method for token verification""" - cookie_cache = self.settings['cookie_cache'] - if encrypted_cookie in cookie_cache: - # we've seen this token before, don't ask upstream again - return cookie_cache[encrypted_cookie] - - hub_api_url = self.settings['hub_api_url'] - hub_api_key = self.settings['hub_api_key'] - r = requests.get(url_path_join( - hub_api_url, "authorizations/cookie", cookie_name, quote(encrypted_cookie, safe=''), - ), - headers = {'Authorization' : 'token %s' % hub_api_key}, - ) - if r.status_code == 404: - data = None - elif r.status_code == 403: - self.log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason) - raise HTTPError(500, "Permission failure checking authorization, I may need to be restarted") - elif r.status_code >= 500: - self.log.error("Upstream failure verifying auth token: [%i] %s", r.status_code, r.reason) - raise HTTPError(502, "Failed to check authorization (upstream problem)") - elif r.status_code >= 400: - self.log.warn("Failed to check authorization: [%i] %s", r.status_code, r.reason) - raise HTTPError(500, "Failed to check authorization") - else: - data = r.json() - cookie_cache[encrypted_cookie] = data - return data - - @staticmethod - def get_user(self): - """alternative get_current_user to query the central server""" - # only allow this to be called once per handler - # avoids issues if an error is raised, - # since this may be called again when trying to render the error page - if hasattr(self, '_cached_user'): - return self._cached_user - - self._cached_user = None - my_user = self.settings['user'] - encrypted_cookie = self.get_cookie(self.cookie_name) - if encrypted_cookie: - auth_data = JupyterHubLoginHandler.verify_token(self, self.cookie_name, encrypted_cookie) - if not auth_data: - # treat invalid token the same as no token - return None - user = auth_data['user'] - if user == my_user: - self._cached_user = user - return user - else: - return None - else: - self.log.debug("No token cookie") - return None - - -class JupyterHubLogoutHandler(LogoutHandler): - def get(self): - self.redirect(url_path_join(self.settings['hub_prefix'], 'logout')) - - -# register new hub related command-line aliases -aliases = NotebookApp.aliases.get_default_value() -aliases.update({ - 'user' : 'SingleUserNotebookApp.user', - 'cookie-name': 'SingleUserNotebookApp.cookie_name', - 'hub-prefix': 'SingleUserNotebookApp.hub_prefix', - 'hub-api-url': 'SingleUserNotebookApp.hub_api_url', - 'base-url': 'SingleUserNotebookApp.base_url', -}) - -class SingleUserNotebookApp(NotebookApp): - """A Subclass of the regular NotebookApp that is aware of the parent multiuser context.""" - user = CUnicode(config=True) - def _user_changed(self, name, old, new): - self.log.name = new - cookie_name = Unicode(config=True) - hub_prefix = Unicode(config=True) - hub_api_url = Unicode(config=True) - aliases = aliases - open_browser = False - login_handler_class = JupyterHubLoginHandler - logout_handler_class = JupyterHubLogoutHandler - - cookie_cache_lifetime = Integer( - config=True, - default_value=300, - allow_none=True, - help=""" - Time, in seconds, that we cache a validated cookie before requiring - revalidation with the hub. - """, - ) - - def _log_datefmt_default(self): - """Exclude date from default date format""" - return "%Y-%m-%d %H:%M:%S" - - def _log_format_default(self): - """override default log format to include time""" - return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s" - - def _confirm_exit(self): - # disable the exit confirmation for background notebook processes - ioloop.IOLoop.instance().stop() - - def _clear_cookie_cache(self): - self.log.info("Clearing cookie cache") - self.tornado_settings['cookie_cache'].clear() - - def initialize(self, argv=None): - super().initialize(argv=argv) - - # Start a PeriodicCallback to clear cached cookies. This forces us to - # revalidate our user with the Hub at least every - # `cookie_cache_lifetime` seconds. - if self.cookie_cache_lifetime: - ioloop.PeriodicCallback( - self._clear_cookie_cache, - self.cookie_cache_lifetime * 1e3, - ).start() - - def init_webapp(self): - # load the hub related settings into the tornado settings dict - env = os.environ - s = self.tornado_settings - s['cookie_cache'] = {} - s['user'] = self.user - s['hub_api_key'] = env.pop('JPY_API_TOKEN') - s['hub_prefix'] = self.hub_prefix - s['cookie_name'] = self.cookie_name - s['login_url'] = url_path_join(self.hub_prefix, 'login') - s['hub_api_url'] = self.hub_api_url - super(SingleUserNotebookApp, self).init_webapp() - - -def main(): - return SingleUserNotebookApp.launch_instance() - - -if __name__ == "__main__": - main() diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 81615955..1ad14d1f 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -7,25 +7,23 @@ import errno import os import pipes import pwd -import re import signal import sys import grp -from subprocess import Popen, check_output, PIPE, CalledProcessError +from subprocess import Popen from tempfile import TemporaryDirectory from tornado import gen from tornado.ioloop import IOLoop, PeriodicCallback -from IPython.config import LoggingConfigurable -from IPython.utils.traitlets import ( - Any, Bool, Dict, Enum, Instance, Integer, Float, List, Unicode, +from traitlets.config import LoggingConfigurable +from traitlets import ( + Any, Bool, Dict, Instance, Integer, Float, List, Unicode, ) +from .traitlets import Command from .utils import random_port -NUM_PAT = re.compile(r'\d+') - class Spawner(LoggingConfigurable): """Base class for spawning single-user notebook servers. @@ -41,8 +39,9 @@ class Spawner(LoggingConfigurable): db = Any() user = Any() hub = Any() + authenticator = Any() api_token = Unicode() - ip = Unicode('localhost', config=True, + ip = Unicode('127.0.0.1', config=True, help="The IP address (or hostname) the single-user server should listen on" ) start_timeout = Integer(60, config=True, @@ -74,6 +73,38 @@ class Spawner(LoggingConfigurable): help="Enable debug-logging of the single-user server" ) + options_form = Unicode("", config=True, help=""" + An HTML form for options a user can specify on launching their server. + The surrounding `
` element and the submit button are already provided. + + For example: + + Set your key: + +
+ Choose a letter: + + """) + + def options_from_form(self, form_data): + """Interpret HTTP form data + + Form data will always arrive as a dict of lists of strings. + Override this function to understand single-values, numbers, etc. + + This should coerce form data into the structure expected by self.user_options, + which must be a dict. + + Instances will receive this data on self.user_options, after passing through this function, + prior to `Spawner.start`. + """ + return form_data + + user_options = Dict(help="This is where form-specified options ultimately end up.") + env_keep = List([ 'PATH', 'PYTHONPATH', @@ -94,7 +125,7 @@ class Spawner(LoggingConfigurable): env['JPY_API_TOKEN'] = self.api_token return env - cmd = List(Unicode, default_value=['jupyterhub-singleuser'], config=True, + cmd = Command(['jupyterhub-singleuser'], config=True, help="""The command used for starting notebooks.""" ) args = List(Unicode, config=True, @@ -105,6 +136,15 @@ class Spawner(LoggingConfigurable): help="""The notebook directory for the single-user server `~` will be expanded to the user's home directory + `%U` will be expanded to the user's username + """ + ) + + disable_user_config = Bool(False, config=True, + help="""Disable per-user configuration of single-user servers. + + This prevents any config in users' $HOME directories + from having an effect on their server. """ ) @@ -132,7 +172,7 @@ class Spawner(LoggingConfigurable): """store the state necessary for load_state A black box of extra state for custom spawners. - Should call `super`. + Subclasses should call `super`. Returns ------- @@ -152,6 +192,14 @@ class Spawner(LoggingConfigurable): """ self.api_token = '' + def get_env(self): + """Return the environment we should use + + Default returns a copy of self.env. + Use this to access the env in Spawner.start to allow extension in subclasses. + """ + return self.env.copy() + def get_args(self): """Return the arguments to be passed after self.cmd""" args = [ @@ -159,15 +207,19 @@ class Spawner(LoggingConfigurable): '--port=%i' % self.user.server.port, '--cookie-name=%s' % self.user.server.cookie_name, '--base-url=%s' % self.user.server.base_url, + '--hub-host=%s' % self.hub.host, '--hub-prefix=%s' % self.hub.server.base_url, '--hub-api-url=%s' % self.hub.api_url, ] if self.ip: args.append('--ip=%s' % self.ip) if self.notebook_dir: + self.notebook_dir = self.notebook_dir.replace("%U",self.user.name) args.append('--notebook-dir=%s' % self.notebook_dir) if self.debug: args.append('--debug') + if self.disable_user_config: + args.append('--disable-user-config') args.extend(self.args) return args @@ -253,7 +305,7 @@ class Spawner(LoggingConfigurable): if status is not None: break else: - yield gen.Task(loop.add_timeout, loop.time() + self.death_interval) + yield gen.sleep(self.death_interval) def _try_setcwd(path): """Try to set CWD, walking up and ultimately falling back to a temp dir""" @@ -276,15 +328,15 @@ def set_user_setuid(username): uid = user.pw_uid gid = user.pw_gid home = user.pw_dir + gids = [ g.gr_gid for g in grp.getgrall() if username in g.gr_mem ] def preexec(): - # don't forward signals - os.setpgrp() - # set the user and group os.setgid(gid) - gids = [ g.gr_gid for g in grp.getgrall() if username in g.gr_mem ] - os.setgroups(gids) + try: + os.setgroups(gids) + except Exception as e: + print('Failed to set groups %s' % e, file=sys.stderr) os.setuid(uid) # start in the user's home dir @@ -294,7 +346,12 @@ def set_user_setuid(username): class LocalProcessSpawner(Spawner): - """A Spawner that just uses Popen to start local processes.""" + """A Spawner that just uses Popen to start local processes as users. + + Requires users to exist on the local system. + + This is the default spawner for JupyterHub. + """ INTERRUPT_TIMEOUT = Integer(10, config=True, help="Seconds to wait for process to halt after SIGINT before proceeding to SIGTERM" @@ -332,13 +389,21 @@ class LocalProcessSpawner(Spawner): def user_env(self, env): env['USER'] = self.user.name - env['HOME'] = pwd.getpwnam(self.user.name).pw_dir - env['SHELL'] = pwd.getpwnam(self.user.name).pw_shell + home = pwd.getpwnam(self.user.name).pw_dir + shell = pwd.getpwnam(self.user.name).pw_shell + # These will be empty if undefined, + # in which case don't set the env: + if home: + env['HOME'] = home + if shell: + env['SHELL'] = shell return env - def _env_default(self): - env = super()._env_default() - return self.user_env(env) + def get_env(self): + """Add user environment variables""" + env = super().get_env() + env = self.user_env(env) + return env @gen.coroutine def start(self): @@ -347,7 +412,7 @@ class LocalProcessSpawner(Spawner): self.user.server.ip = self.ip self.user.server.port = random_port() cmd = [] - env = self.env.copy() + env = self.get_env() cmd.extend(self.cmd) cmd.extend(self.get_args()) @@ -355,6 +420,7 @@ class LocalProcessSpawner(Spawner): self.log.info("Spawning %s", ' '.join(pipes.quote(s) for s in cmd)) self.proc = Popen(cmd, env=env, preexec_fn=self.make_preexec_fn(self.user.name), + start_new_session=True, # don't forward signals ) self.pid = self.proc.pid diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index 39cf9477..b3eb763b 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -1,31 +1,36 @@ """mock utilities for testing""" +import os import sys -from datetime import timedelta from tempfile import NamedTemporaryFile import threading from unittest import mock +import requests + from tornado import gen from tornado.concurrent import Future from tornado.ioloop import IOLoop -from ..spawner import LocalProcessSpawner from ..app import JupyterHub from ..auth import PAMAuthenticator from .. import orm +from ..spawner import LocalProcessSpawner +from ..utils import url_path_join + +from pamela import PAMError def mock_authenticate(username, password, service='login'): - # mimic simplepam's failure to handle unicode - if isinstance(username, str): - return False - if isinstance(password, str): - return False - # just use equality for testing if password == username: return True + else: + raise PAMError("Fake") + + +def mock_open_session(username, service): + pass class MockSpawner(LocalProcessSpawner): @@ -49,12 +54,12 @@ class SlowSpawner(MockSpawner): @gen.coroutine def start(self): - yield gen.Task(IOLoop.current().add_timeout, timedelta(seconds=2)) yield super().start() + yield gen.sleep(2) @gen.coroutine def stop(self): - yield gen.Task(IOLoop.current().add_timeout, timedelta(seconds=2)) + yield gen.sleep(2) yield super().stop() @@ -69,22 +74,48 @@ class NeverSpawner(MockSpawner): return Future() +class FormSpawner(MockSpawner): + options_form = "IMAFORM" + + def options_from_form(self, form_data): + options = {} + options['notspecified'] = 5 + if 'bounds' in form_data: + options['bounds'] = [int(i) for i in form_data['bounds']] + if 'energy' in form_data: + options['energy'] = form_data['energy'][0] + if 'hello_file' in form_data: + options['hello'] = form_data['hello_file'][0] + return options + + class MockPAMAuthenticator(PAMAuthenticator): + def _admin_users_default(self): + return {'admin'} + def system_user_exists(self, user): # skip the add-system-user bit return not user.name.startswith('dne') def authenticate(self, *args, **kwargs): - with mock.patch('simplepam.authenticate', mock_authenticate): + with mock.patch.multiple('pamela', + authenticate=mock_authenticate, + open_session=mock_open_session, + close_session=mock_open_session, + ): return super(MockPAMAuthenticator, self).authenticate(*args, **kwargs) class MockHub(JupyterHub): """Hub with various mock bits""" db_file = None - + confirm_no_ssl = True + + def _subdomain_host_default(self): + return os.environ.get('JUPYTERHUB_TEST_SUBDOMAIN_HOST', '') + def _ip_default(self): - return 'localhost' + return '127.0.0.1' def _authenticator_class_default(self): return MockPAMAuthenticator @@ -92,15 +123,18 @@ class MockHub(JupyterHub): def _spawner_class_default(self): return MockSpawner - def _admin_users_default(self): - return {'admin'} + def init_signal(self): + pass def start(self, argv=None): self.db_file = NamedTemporaryFile() self.db_url = 'sqlite:///' + self.db_file.name + evt = threading.Event() + @gen.coroutine def _start_co(): + assert self.io_loop._running # put initialize in start for SQLAlchemy threading reasons yield super(MockHub, self).initialize(argv=argv) # add an initial user @@ -108,16 +142,19 @@ class MockHub(JupyterHub): self.db.add(user) self.db.commit() yield super(MockHub, self).start() + yield self.hub.server.wait_up(http=True) self.io_loop.add_callback(evt.set) def _start(): - self.io_loop = IOLoop.current() + self.io_loop = IOLoop() + self.io_loop.make_current() self.io_loop.add_callback(_start_co) self.io_loop.start() self._thread = threading.Thread(target=_start) self._thread.start() - evt.wait(timeout=5) + ready = evt.wait(timeout=10) + assert ready def stop(self): super().stop() @@ -126,3 +163,34 @@ class MockHub(JupyterHub): # ignore the call that will fire in atexit self.cleanup = lambda : None self.db_file.close() + + def login_user(self, name): + base_url = public_url(self) + r = requests.post(base_url + 'hub/login', + data={ + 'username': name, + 'password': name, + }, + allow_redirects=False, + ) + assert r.cookies + return r.cookies + + +def public_host(app): + if app.subdomain_host: + return app.subdomain_host + else: + return app.proxy.public_server.host + + +def public_url(app): + return public_host(app) + app.proxy.public_server.base_url + + +def user_url(user, app): + if app.subdomain_host: + host = user.host + else: + host = public_host(app) + return host + user.server.base_url diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 45848144..bd5408d7 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -1,15 +1,19 @@ """Tests for the REST API""" import json -from datetime import timedelta +import time +from queue import Queue +from urllib.parse import urlparse import requests from tornado import gen -from ..utils import url_path_join as ujoin from .. import orm +from ..user import User +from ..utils import url_path_join as ujoin from . import mocking +from .mocking import public_url, user_url def check_db_locks(func): @@ -38,11 +42,15 @@ def check_db_locks(func): def find_user(db, name): return db.query(orm.User).filter(orm.User.name==name).first() -def add_user(db, **kwargs): - user = orm.User(**kwargs) - db.add(user) +def add_user(db, app=None, **kwargs): + orm_user = orm.User(**kwargs) + db.add(orm_user) db.commit() - return user + if app: + user = app.users[orm_user.id] = User(orm_user, app.tornado_settings) + return user + else: + return orm_user def auth_header(db, name): user = find_user(db, name) @@ -59,11 +67,15 @@ def api_request(app, *api_path, **kwargs): if 'Authorization' not in headers: headers.update(auth_header(app.db, 'admin')) - + url = ujoin(base_url, 'api', *api_path) method = kwargs.pop('method', 'get') f = getattr(requests, method) - return f(url, **kwargs) + resp = f(url, **kwargs) + assert "frame-ancestors 'self'" in resp.headers['Content-Security-Policy'] + assert ujoin(app.hub.server.base_url, "security/csp-report") in resp.headers['Content-Security-Policy'] + assert 'http' not in resp.headers['Content-Security-Policy'] + return resp def test_auth_api(app): db = app.db @@ -78,7 +90,7 @@ def test_auth_api(app): r = api_request(app, 'authorizations/token', api_token) assert r.status_code == 200 reply = r.json() - assert reply['user'] == user.name + assert reply['name'] == user.name # check fail r = api_request(app, 'authorizations/token', api_token, @@ -91,6 +103,51 @@ def test_auth_api(app): ) assert r.status_code == 403 + +def test_referer_check(app, io_loop): + url = ujoin(public_url(app), app.hub.server.base_url) + host = urlparse(url).netloc + user = find_user(app.db, 'admin') + if user is None: + user = add_user(app.db, name='admin', admin=True) + cookies = app.login_user('admin') + app_user = get_app_user(app, 'admin') + # stop the admin's server so we don't mess up future tests + io_loop.run_sync(lambda : app.proxy.delete_user(app_user)) + io_loop.run_sync(app_user.stop) + + r = api_request(app, 'users', + headers={ + 'Authorization': '', + 'Referer': 'null', + }, cookies=cookies, + ) + assert r.status_code == 403 + r = api_request(app, 'users', + headers={ + 'Authorization': '', + 'Referer': 'http://attack.com/csrf/vulnerability', + }, cookies=cookies, + ) + assert r.status_code == 403 + r = api_request(app, 'users', + headers={ + 'Authorization': '', + 'Referer': url, + 'Host': host, + }, cookies=cookies, + ) + assert r.status_code == 200 + r = api_request(app, 'users', + headers={ + 'Authorization': '', + 'Referer': ujoin(url, 'foo/bar/baz/bat'), + 'Host': host, + }, cookies=cookies, + ) + assert r.status_code == 200 + + def test_get_users(app): db = app.db r = api_request(app, 'users') @@ -129,6 +186,93 @@ def test_add_user(app): assert user.name == name assert not user.admin + +def test_get_user(app): + name = 'user' + r = api_request(app, 'users', name) + assert r.status_code == 200 + user = r.json() + user.pop('last_activity') + assert user == { + 'name': name, + 'admin': False, + 'server': None, + 'pending': None, + } + + +def test_add_multi_user_bad(app): + r = api_request(app, 'users', method='post') + assert r.status_code == 400 + r = api_request(app, 'users', method='post', data='{}') + assert r.status_code == 400 + r = api_request(app, 'users', method='post', data='[]') + assert r.status_code == 400 + + +def test_add_multi_user_invalid(app): + app.authenticator.username_pattern = r'w.*' + r = api_request(app, 'users', method='post', + data=json.dumps({'usernames': ['Willow', 'Andrew', 'Tara']}) + ) + app.authenticator.username_pattern = '' + assert r.status_code == 400 + assert r.json()['message'] == 'Invalid usernames: andrew, tara' + + +def test_add_multi_user(app): + db = app.db + names = ['a', 'b'] + r = api_request(app, 'users', method='post', + data=json.dumps({'usernames': names}), + ) + assert r.status_code == 201 + reply = r.json() + r_names = [ user['name'] for user in reply ] + assert names == r_names + + for name in names: + user = find_user(db, name) + assert user is not None + assert user.name == name + assert not user.admin + + # try to create the same users again + r = api_request(app, 'users', method='post', + data=json.dumps({'usernames': names}), + ) + assert r.status_code == 400 + + names = ['a', 'b', 'ab'] + + # try to create the same users again + r = api_request(app, 'users', method='post', + data=json.dumps({'usernames': names}), + ) + assert r.status_code == 201 + reply = r.json() + r_names = [ user['name'] for user in reply ] + assert r_names == ['ab'] + + +def test_add_multi_user_admin(app): + db = app.db + names = ['c', 'd'] + r = api_request(app, 'users', method='post', + data=json.dumps({'usernames': names, 'admin': True}), + ) + assert r.status_code == 201 + reply = r.json() + r_names = [ user['name'] for user in reply ] + assert names == r_names + + for name in names: + user = find_user(db, name) + assert user is not None + assert user.name == name + assert user.admin + + def test_add_user_bad(app): db = app.db name = 'dne_newuser' @@ -175,107 +319,131 @@ def test_make_admin(app): assert user.name == name assert user.admin +def get_app_user(app, name): + """Get the User object from the main thread + + Needed for access to the Spawner. + No ORM methods should be called on the result. + """ + q = Queue() + def get_user_id(): + user = find_user(app.db, name) + q.put(user.id) + app.io_loop.add_callback(get_user_id) + user_id = q.get(timeout=2) + return app.users[user_id] def test_spawn(app, io_loop): db = app.db name = 'wash' - user = add_user(db, name=name) - r = api_request(app, 'users', name, 'server', method='post') + user = add_user(db, app=app, name=name) + options = { + 's': ['value'], + 'i': 5, + } + r = api_request(app, 'users', name, 'server', method='post', data=json.dumps(options)) assert r.status_code == 201 assert 'pid' in user.state - assert user.spawner is not None - assert not user.spawn_pending - status = io_loop.run_sync(user.spawner.poll) + app_user = get_app_user(app, name) + assert app_user.spawner is not None + assert app_user.spawner.user_options == options + assert not app_user.spawn_pending + status = io_loop.run_sync(app_user.spawner.poll) assert status is None assert user.server.base_url == '/user/%s' % name - r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url)) + url = user_url(user, app) + print(url) + r = requests.get(url) assert r.status_code == 200 assert r.text == user.server.base_url - r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url, 'args')) + r = requests.get(ujoin(url, 'args')) assert r.status_code == 200 argv = r.json() for expected in ['--user=%s' % name, '--base-url=%s' % user.server.base_url]: assert expected in argv + if app.subdomain_host: + assert '--hub-host=%s' % app.subdomain_host in argv r = api_request(app, 'users', name, 'server', method='delete') assert r.status_code == 204 assert 'pid' not in user.state - status = io_loop.run_sync(user.spawner.poll) + status = io_loop.run_sync(app_user.spawner.poll) assert status == 0 def test_slow_spawn(app, io_loop): - app.tornado_application.settings['spawner_class'] = mocking.SlowSpawner + # app.tornado_application.settings['spawner_class'] = mocking.SlowSpawner + app.tornado_settings['spawner_class'] = mocking.SlowSpawner app.tornado_application.settings['slow_spawn_timeout'] = 0 app.tornado_application.settings['slow_stop_timeout'] = 0 db = app.db name = 'zoe' - user = add_user(db, name=name) + user = add_user(db, app=app, name=name) r = api_request(app, 'users', name, 'server', method='post') r.raise_for_status() assert r.status_code == 202 - assert user.spawner is not None - assert user.spawn_pending - assert not user.stop_pending + app_user = get_app_user(app, name) + assert app_user.spawner is not None + assert app_user.spawn_pending + assert not app_user.stop_pending - dt = timedelta(seconds=0.1) @gen.coroutine def wait_spawn(): - while user.spawn_pending: - yield gen.Task(io_loop.add_timeout, dt) + while app_user.spawn_pending: + yield gen.sleep(0.1) io_loop.run_sync(wait_spawn) - assert not user.spawn_pending - status = io_loop.run_sync(user.spawner.poll) + assert not app_user.spawn_pending + status = io_loop.run_sync(app_user.spawner.poll) assert status is None @gen.coroutine def wait_stop(): - while user.stop_pending: - yield gen.Task(io_loop.add_timeout, dt) + while app_user.stop_pending: + yield gen.sleep(0.1) r = api_request(app, 'users', name, 'server', method='delete') r.raise_for_status() assert r.status_code == 202 - assert user.spawner is not None - assert user.stop_pending + assert app_user.spawner is not None + assert app_user.stop_pending r = api_request(app, 'users', name, 'server', method='delete') r.raise_for_status() assert r.status_code == 202 - assert user.spawner is not None - assert user.stop_pending + assert app_user.spawner is not None + assert app_user.stop_pending io_loop.run_sync(wait_stop) - assert not user.stop_pending - assert user.spawner is not None + assert not app_user.stop_pending + assert app_user.spawner is not None r = api_request(app, 'users', name, 'server', method='delete') assert r.status_code == 400 def test_never_spawn(app, io_loop): - app.tornado_application.settings['spawner_class'] = mocking.NeverSpawner + app.tornado_settings['spawner_class'] = mocking.NeverSpawner app.tornado_application.settings['slow_spawn_timeout'] = 0 db = app.db name = 'badger' - user = add_user(db, name=name) + user = add_user(db, app=app, name=name) r = api_request(app, 'users', name, 'server', method='post') - assert user.spawner is not None - assert user.spawn_pending + app_user = get_app_user(app, name) + assert app_user.spawner is not None + assert app_user.spawn_pending - dt = timedelta(seconds=0.1) @gen.coroutine def wait_pending(): - while user.spawn_pending: - yield gen.Task(io_loop.add_timeout, dt) + while app_user.spawn_pending: + yield gen.sleep(0.1) io_loop.run_sync(wait_pending) - assert not user.spawn_pending - status = io_loop.run_sync(user.spawner.poll) + assert not app_user.spawn_pending + status = io_loop.run_sync(app_user.spawner.poll) assert status is not None @@ -284,3 +452,18 @@ def test_get_proxy(app, io_loop): r.raise_for_status() reply = r.json() assert list(reply.keys()) == ['/'] + + +def test_shutdown(app): + r = api_request(app, 'shutdown', method='post', data=json.dumps({ + 'servers': True, + 'proxy': True, + })) + r.raise_for_status() + reply = r.json() + for i in range(100): + if app.io_loop._running: + time.sleep(0.1) + else: + break + assert not app.io_loop._running diff --git a/jupyterhub/tests/test_app.py b/jupyterhub/tests/test_app.py index f4f59d13..fcd03ff4 100644 --- a/jupyterhub/tests/test_app.py +++ b/jupyterhub/tests/test_app.py @@ -3,7 +3,6 @@ import os import re import sys -from getpass import getuser from subprocess import check_output from tempfile import NamedTemporaryFile, TemporaryDirectory @@ -16,7 +15,9 @@ def test_token_app(): cmd = [sys.executable, '-m', 'jupyterhub', 'token'] out = check_output(cmd + ['--help-all']).decode('utf8', 'replace') with TemporaryDirectory() as td: - out = check_output(cmd + [getuser()], cwd=td).decode('utf8', 'replace').strip() + with open(os.path.join(td, 'jupyterhub_config.py'), 'w') as f: + f.write("c.Authenticator.admin_users={'user'}") + out = check_output(cmd + ['user'], cwd=td).decode('utf8', 'replace').strip() assert re.match(r'^[a-z0-9]+$', out) def test_generate_config(): diff --git a/jupyterhub/tests/test_auth.py b/jupyterhub/tests/test_auth.py index 8e5103c7..92722b07 100644 --- a/jupyterhub/tests/test_auth.py +++ b/jupyterhub/tests/test_auth.py @@ -3,18 +3,22 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +from unittest import mock + +import pytest from .mocking import MockPAMAuthenticator +from jupyterhub import auth, orm def test_pam_auth(io_loop): authenticator = MockPAMAuthenticator() - authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { 'username': 'match', 'password': 'match', })) assert authorized == 'match' - authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { 'username': 'match', 'password': 'nomatch', })) @@ -22,20 +26,171 @@ def test_pam_auth(io_loop): def test_pam_auth_whitelist(io_loop): authenticator = MockPAMAuthenticator(whitelist={'wash', 'kaylee'}) - authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { 'username': 'kaylee', 'password': 'kaylee', })) assert authorized == 'kaylee' - authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { 'username': 'wash', 'password': 'nomatch', })) assert authorized is None - authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, { + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { 'username': 'mal', 'password': 'mal', })) assert authorized is None + + +class MockGroup: + def __init__(self, *names): + self.gr_mem = names + + +def test_pam_auth_group_whitelist(io_loop): + g = MockGroup('kaylee') + def getgrnam(name): + return g + + authenticator = MockPAMAuthenticator(group_whitelist={'group'}) + + with mock.patch.object(auth, 'getgrnam', getgrnam): + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { + 'username': 'kaylee', + 'password': 'kaylee', + })) + assert authorized == 'kaylee' + + with mock.patch.object(auth, 'getgrnam', getgrnam): + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { + 'username': 'mal', + 'password': 'mal', + })) + assert authorized is None + + +def test_pam_auth_no_such_group(io_loop): + authenticator = MockPAMAuthenticator(group_whitelist={'nosuchcrazygroup'}) + authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, { + 'username': 'kaylee', + 'password': 'kaylee', + })) + assert authorized is None + + +def test_wont_add_system_user(io_loop): + user = orm.User(name='lioness4321') + authenticator = auth.PAMAuthenticator(whitelist={'mal'}) + authenticator.create_system_users = False + with pytest.raises(KeyError): + io_loop.run_sync(lambda : authenticator.add_user(user)) + + +def test_cant_add_system_user(io_loop): + user = orm.User(name='lioness4321') + authenticator = auth.PAMAuthenticator(whitelist={'mal'}) + authenticator.add_user_cmd = ['jupyterhub-fake-command'] + authenticator.create_system_users = True + + class DummyFile: + def read(self): + return b'dummy error' + + class DummyPopen: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + self.returncode = 1 + self.stdout = DummyFile() + + def wait(self): + return + + with mock.patch.object(auth, 'Popen', DummyPopen): + with pytest.raises(RuntimeError) as exc: + io_loop.run_sync(lambda : authenticator.add_user(user)) + assert str(exc.value) == 'Failed to create system user lioness4321: dummy error' + + +def test_add_system_user(io_loop): + user = orm.User(name='lioness4321') + authenticator = auth.PAMAuthenticator(whitelist={'mal'}) + authenticator.create_system_users = True + authenticator.add_user_cmd = ['echo', '/home/USERNAME'] + + record = {} + class DummyPopen: + def __init__(self, cmd, *args, **kwargs): + record['cmd'] = cmd + self.returncode = 0 + + def wait(self): + return + + with mock.patch.object(auth, 'Popen', DummyPopen): + io_loop.run_sync(lambda : authenticator.add_user(user)) + assert record['cmd'] == ['echo', '/home/lioness4321', 'lioness4321'] + + +def test_delete_user(io_loop): + user = orm.User(name='zoe') + a = MockPAMAuthenticator(whitelist={'mal'}) + + assert 'zoe' not in a.whitelist + a.add_user(user) + assert 'zoe' in a.whitelist + a.delete_user(user) + assert 'zoe' not in a.whitelist + + +def test_urls(): + a = auth.PAMAuthenticator() + logout = a.logout_url('/base/url/') + login = a.login_url('/base/url') + assert logout == '/base/url/logout' + assert login == '/base/url/login' + + +def test_handlers(app): + a = auth.PAMAuthenticator() + handlers = a.get_handlers(app) + assert handlers[0][0] == '/login' + + +def test_normalize_names(io_loop): + a = MockPAMAuthenticator() + authorized = io_loop.run_sync(lambda : a.get_authenticated_user(None, { + 'username': 'ZOE', + 'password': 'ZOE', + })) + assert authorized == 'zoe' + + +def test_username_map(io_loop): + a = MockPAMAuthenticator(username_map={'wash': 'alpha'}) + authorized = io_loop.run_sync(lambda : a.get_authenticated_user(None, { + 'username': 'WASH', + 'password': 'WASH', + })) + + assert authorized == 'alpha' + + authorized = io_loop.run_sync(lambda : a.get_authenticated_user(None, { + 'username': 'Inara', + 'password': 'Inara', + })) + assert authorized == 'inara' + + +def test_validate_names(io_loop): + a = auth.PAMAuthenticator() + assert a.validate_username('willow') + assert a.validate_username('giles') + a = auth.PAMAuthenticator(username_pattern='w.*') + assert not a.validate_username('xander') + assert a.validate_username('willow') + + diff --git a/jupyterhub/tests/test_orm.py b/jupyterhub/tests/test_orm.py index 9aa6dc22..983c4d12 100644 --- a/jupyterhub/tests/test_orm.py +++ b/jupyterhub/tests/test_orm.py @@ -7,6 +7,7 @@ import pytest from tornado import gen from .. import orm +from ..user import User from .mocking import MockSpawner @@ -19,7 +20,7 @@ def test_server(db): assert server.proto == 'http' assert isinstance(server.port, int) assert isinstance(server.cookie_name, str) - assert server.host == 'http://localhost:%i' % server.port + assert server.host == 'http://127.0.0.1:%i' % server.port assert server.url == server.host + '/' assert server.bind_url == 'http://*:%i/' % server.port server.ip = '127.0.0.1' @@ -94,8 +95,8 @@ def test_tokens(db): def test_spawn_fails(db, io_loop): - user = orm.User(name='aeofel') - db.add(user) + orm_user = orm.User(name='aeofel') + db.add(orm_user) db.commit() class BadSpawner(MockSpawner): @@ -103,8 +104,13 @@ def test_spawn_fails(db, io_loop): def start(self): raise RuntimeError("Split the party") + user = User(orm_user, { + 'spawner_class': BadSpawner, + 'config': None, + }) + with pytest.raises(Exception) as exc: - io_loop.run_sync(lambda : user.spawn(BadSpawner)) + io_loop.run_sync(user.spawn) assert user.server is None assert not user.running diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py new file mode 100644 index 00000000..c4a6957b --- /dev/null +++ b/jupyterhub/tests/test_pages.py @@ -0,0 +1,190 @@ +"""Tests for HTML pages""" + +from urllib.parse import urlencode, urlparse + +import requests + +from ..utils import url_path_join as ujoin +from .. import orm + +import mock +from .mocking import FormSpawner, public_url, public_host, user_url +from .test_api import api_request + +def get_page(path, app, **kw): + base_url = ujoin(public_url(app), app.hub.server.base_url) + print(base_url) + return requests.get(ujoin(base_url, path), **kw) + +def test_root_no_auth(app, io_loop): + print(app.hub.server.is_up()) + routes = io_loop.run_sync(app.proxy.get_routes) + print(routes) + print(app.hub.server) + url = public_url(app) + print(url) + r = requests.get(url) + r.raise_for_status() + assert r.url == ujoin(url, app.hub.server.base_url, 'login') + +def test_root_auth(app): + cookies = app.login_user('river') + r = requests.get(public_url(app), cookies=cookies) + r.raise_for_status() + assert r.url == user_url(app.users['river'], app) + +def test_home_no_auth(app): + r = get_page('home', app, allow_redirects=False) + r.raise_for_status() + assert r.status_code == 302 + assert '/hub/login' in r.headers['Location'] + +def test_home_auth(app): + cookies = app.login_user('river') + r = get_page('home', app, cookies=cookies) + r.raise_for_status() + assert r.url.endswith('home') + +def test_admin_no_auth(app): + r = get_page('admin', app) + assert r.status_code == 403 + +def test_admin_not_admin(app): + cookies = app.login_user('wash') + r = get_page('admin', app, cookies=cookies) + assert r.status_code == 403 + +def test_admin(app): + cookies = app.login_user('river') + u = orm.User.find(app.db, 'river') + u.admin = True + app.db.commit() + r = get_page('admin', app, cookies=cookies) + r.raise_for_status() + assert r.url.endswith('/admin') + +def test_spawn_redirect(app, io_loop): + name = 'wash' + cookies = app.login_user(name) + u = app.users[orm.User.find(app.db, name)] + + # ensure wash's server isn't running: + r = api_request(app, 'users', name, 'server', method='delete', cookies=cookies) + r.raise_for_status() + status = io_loop.run_sync(u.spawner.poll) + assert status is not None + + # test spawn page when no server is running + r = get_page('spawn', app, cookies=cookies) + r.raise_for_status() + print(urlparse(r.url)) + path = urlparse(r.url).path + assert path == '/user/%s' % name + + # should have started server + status = io_loop.run_sync(u.spawner.poll) + assert status is None + + # test spawn page when server is already running (just redirect) + r = get_page('spawn', app, cookies=cookies) + r.raise_for_status() + print(urlparse(r.url)) + path = urlparse(r.url).path + assert path == '/user/%s' % name + +def test_spawn_page(app): + with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}): + cookies = app.login_user('jones') + r = get_page('spawn', app, cookies=cookies) + assert r.url.endswith('/spawn') + assert FormSpawner.options_form in r.text + +def test_spawn_form(app, io_loop): + with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}): + base_url = ujoin(public_url(app), app.hub.server.base_url) + cookies = app.login_user('jones') + orm_u = orm.User.find(app.db, 'jones') + u = app.users[orm_u] + io_loop.run_sync(u.stop) + + r = requests.post(ujoin(base_url, 'spawn'), cookies=cookies, data={ + 'bounds': ['-1', '1'], + 'energy': '511keV', + }) + r.raise_for_status() + print(u.spawner) + print(u.spawner.user_options) + assert u.spawner.user_options == { + 'energy': '511keV', + 'bounds': [-1, 1], + 'notspecified': 5, + } + +def test_spawn_form_with_file(app, io_loop): + with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}): + base_url = ujoin(public_url(app), app.hub.server.base_url) + cookies = app.login_user('jones') + orm_u = orm.User.find(app.db, 'jones') + u = app.users[orm_u] + io_loop.run_sync(u.stop) + + r = requests.post(ujoin(base_url, 'spawn'), + cookies=cookies, + data={ + 'bounds': ['-1', '1'], + 'energy': '511keV', + }, + files={'hello': ('hello.txt', b'hello world\n')} + ) + r.raise_for_status() + print(u.spawner) + print(u.spawner.user_options) + assert u.spawner.user_options == { + 'energy': '511keV', + 'bounds': [-1, 1], + 'notspecified': 5, + 'hello': {'filename': 'hello.txt', + 'body': b'hello world\n', + 'content_type': 'application/unknown'}, + } + + +def test_user_redirect(app): + name = 'wash' + cookies = app.login_user(name) + + r = get_page('/user/baduser', app, cookies=cookies) + r.raise_for_status() + print(urlparse(r.url)) + path = urlparse(r.url).path + assert path == '/user/%s' % name + + r = get_page('/user/baduser/test.ipynb', app, cookies=cookies) + r.raise_for_status() + print(urlparse(r.url)) + path = urlparse(r.url).path + assert path == '/user/%s/test.ipynb' % name + + r = get_page('/user/baduser/test.ipynb', app) + r.raise_for_status() + print(urlparse(r.url)) + path = urlparse(r.url).path + assert path == '/hub/login' + query = urlparse(r.url).query + assert query == urlencode({'next': '/hub/user/baduser/test.ipynb'}) + + +def test_static_files(app): + base_url = ujoin(public_url(app), app.hub.server.base_url) + print(base_url) + r = requests.get(ujoin(base_url, 'logo')) + r.raise_for_status() + assert r.headers['content-type'] == 'image/png' + r = requests.get(ujoin(base_url, 'static', 'images', 'jupyter.png')) + r.raise_for_status() + assert r.headers['content-type'] == 'image/png' + r = requests.get(ujoin(base_url, 'static', 'css', 'style.min.css')) + r.raise_for_status() + assert r.headers['content-type'] == 'text/css' + + \ No newline at end of file diff --git a/jupyterhub/tests/test_proxy.py b/jupyterhub/tests/test_proxy.py index 55be87c0..9947d829 100644 --- a/jupyterhub/tests/test_proxy.py +++ b/jupyterhub/tests/test_proxy.py @@ -2,7 +2,9 @@ import json import os +from queue import Queue from subprocess import Popen +from urllib.parse import urlparse from .. import orm from .mocking import MockHub @@ -26,13 +28,15 @@ def test_external_proxy(request, io_loop): request.addfinalizer(fin) env = os.environ.copy() env['CONFIGPROXY_AUTH_TOKEN'] = auth_token - cmd = [app.proxy_cmd, + cmd = app.proxy_cmd + [ '--ip', app.ip, '--port', str(app.port), '--api-ip', proxy_ip, '--api-port', str(proxy_port), '--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port), ] + if app.subdomain_host: + cmd.append('--host-routing') proxy = Popen(cmd, env=env) def _cleanup_proxy(): if proxy.poll() is None: @@ -59,7 +63,11 @@ def test_external_proxy(request, io_loop): r.raise_for_status() routes = io_loop.run_sync(app.proxy.get_routes) - assert sorted(routes.keys()) == ['/', '/user/river'] + user_path = '/user/river' + if app.subdomain_host: + domain = urlparse(app.subdomain_host).hostname + user_path = '/%s.%s' % (name, domain) + user_path + assert sorted(routes.keys()) == ['/', user_path] # teardown the proxy and start a new one in the same place proxy.terminate() @@ -75,21 +83,22 @@ def test_external_proxy(request, io_loop): # check that the routes are correct routes = io_loop.run_sync(app.proxy.get_routes) - assert sorted(routes.keys()) == ['/', '/user/river'] + assert sorted(routes.keys()) == ['/', user_path] # teardown the proxy again, and start a new one with different auth and port proxy.terminate() new_auth_token = 'different!' env['CONFIGPROXY_AUTH_TOKEN'] = new_auth_token proxy_port = 55432 - cmd = [app.proxy_cmd, + cmd = app.proxy_cmd + [ '--ip', app.ip, '--port', str(app.port), '--api-ip', app.proxy_api_ip, '--api-port', str(proxy_port), '--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port), ] - + if app.subdomain_host: + cmd.append('--host-routing') proxy = Popen(cmd, env=env) wait_for_proxy() @@ -100,11 +109,19 @@ def test_external_proxy(request, io_loop): })) r.raise_for_status() assert app.proxy.api_server.port == proxy_port - assert app.proxy.auth_token == new_auth_token + + # get updated auth token from main thread + def get_app_proxy_token(): + q = Queue() + app.io_loop.add_callback(lambda : q.put(app.proxy.auth_token)) + return q.get(timeout=2) + + assert get_app_proxy_token() == new_auth_token + app.proxy.auth_token = new_auth_token # check that the routes are correct routes = io_loop.run_sync(app.proxy.get_routes) - assert sorted(routes.keys()) == ['/', '/user/river'] + assert sorted(routes.keys()) == ['/', user_path] def test_check_routes(app, io_loop): proxy = app.proxy @@ -114,13 +131,14 @@ def test_check_routes(app, io_loop): r.raise_for_status() zoe = orm.User.find(app.db, 'zoe') assert zoe is not None + zoe = app.users[zoe] before = sorted(io_loop.run_sync(app.proxy.get_routes)) - assert '/user/zoe' in before - io_loop.run_sync(app.proxy.check_routes) + assert zoe.proxy_path in before + io_loop.run_sync(lambda : app.proxy.check_routes(app.users)) io_loop.run_sync(lambda : proxy.delete_user(zoe)) during = sorted(io_loop.run_sync(app.proxy.get_routes)) - assert '/user/zoe' not in during - io_loop.run_sync(app.proxy.check_routes) + assert zoe.proxy_path not in during + io_loop.run_sync(lambda : app.proxy.check_routes(app.users)) after = sorted(io_loop.run_sync(app.proxy.get_routes)) - assert '/user/zoe' in after + assert zoe.proxy_path in after assert before == after diff --git a/jupyterhub/tests/test_spawner.py b/jupyterhub/tests/test_spawner.py index 8c8a9b24..69ed7a5a 100644 --- a/jupyterhub/tests/test_spawner.py +++ b/jupyterhub/tests/test_spawner.py @@ -45,7 +45,7 @@ def new_spawner(db, **kwargs): def test_spawner(db, io_loop): spawner = new_spawner(db) io_loop.run_sync(spawner.start) - assert spawner.user.server.ip == 'localhost' + assert spawner.user.server.ip == '127.0.0.1' # wait for the process to get to the while True: loop time.sleep(1) @@ -56,6 +56,30 @@ def test_spawner(db, io_loop): status = io_loop.run_sync(spawner.poll) assert status == 1 +def test_single_user_spawner(db, io_loop): + spawner = new_spawner(db, cmd=['jupyterhub-singleuser']) + io_loop.run_sync(spawner.start) + assert spawner.user.server.ip == '127.0.0.1' + # wait for http server to come up, + # checking for early termination every 1s + def wait(): + return spawner.user.server.wait_up(timeout=1, http=True) + for i in range(30): + status = io_loop.run_sync(spawner.poll) + assert status is None + try: + io_loop.run_sync(wait) + except TimeoutError: + continue + else: + break + io_loop.run_sync(wait) + status = io_loop.run_sync(spawner.poll) + assert status == None + io_loop.run_sync(spawner.stop) + status = io_loop.run_sync(spawner.poll) + assert status == 0 + def test_stop_spawner_sigint_fails(db, io_loop): spawner = new_spawner(db, cmd=[sys.executable, '-c', _uninterruptible]) diff --git a/jupyterhub/tests/test_traitlets.py b/jupyterhub/tests/test_traitlets.py new file mode 100644 index 00000000..73eef270 --- /dev/null +++ b/jupyterhub/tests/test_traitlets.py @@ -0,0 +1,27 @@ +from traitlets import HasTraits + +from jupyterhub.traitlets import URLPrefix, Command + +def test_url_prefix(): + class C(HasTraits): + url = URLPrefix() + + c = C() + c.url = '/a/b/c/' + assert c.url == '/a/b/c/' + c.url = '/a/b' + assert c.url == '/a/b/' + c.url = 'a/b/c/d' + assert c.url == '/a/b/c/d/' + +def test_command(): + class C(HasTraits): + cmd = Command('default command') + cmd2 = Command(['default_cmd']) + + c = C() + assert c.cmd == ['default command'] + assert c.cmd2 == ['default_cmd'] + c.cmd = 'foo bar' + assert c.cmd == ['foo bar'] + diff --git a/jupyterhub/traitlets.py b/jupyterhub/traitlets.py index 065ac1d5..f9542747 100644 --- a/jupyterhub/traitlets.py +++ b/jupyterhub/traitlets.py @@ -2,7 +2,7 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -from IPython.utils.traitlets import Unicode +from traitlets import List, Unicode class URLPrefix(Unicode): def validate(self, obj, value): @@ -12,3 +12,18 @@ class URLPrefix(Unicode): if not u.endswith('/'): u = u + '/' return u + +class Command(List): + """Traitlet for a command that should be a list of strings, + but allows it to be specified as a single string. + """ + def __init__(self, default_value=None, **kwargs): + kwargs.setdefault('minlen', 1) + if isinstance(default_value, str): + default_value = [default_value] + super().__init__(Unicode, default_value, **kwargs) + + def validate(self, obj, value): + if isinstance(value, str): + value = [value] + return super().validate(obj, value) diff --git a/jupyterhub/user.py b/jupyterhub/user.py new file mode 100644 index 00000000..e7b79c07 --- /dev/null +++ b/jupyterhub/user.py @@ -0,0 +1,308 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +from datetime import datetime, timedelta +from urllib.parse import quote, urlparse + +from tornado import gen +from tornado.log import app_log + +from sqlalchemy import inspect + +from .utils import url_path_join + +from . import orm +from traitlets import HasTraits, Any, Dict +from .spawner import LocalProcessSpawner + + +class UserDict(dict): + """Like defaultdict, but for users + + Getting by a user id OR an orm.User instance returns a User wrapper around the orm user. + """ + def __init__(self, db_factory, settings): + self.db_factory = db_factory + self.settings = settings + super().__init__() + + @property + def db(self): + return self.db_factory() + + def __contains__(self, key): + if isinstance(key, (User, orm.User)): + key = key.id + return dict.__contains__(self, key) + + def __getitem__(self, key): + if isinstance(key, User): + key = key.id + elif isinstance(key, str): + orm_user = self.db.query(orm.User).filter(orm.User.name==key).first() + if orm_user is None: + raise KeyError("No such user: %s" % name) + else: + key = orm_user + if isinstance(key, orm.User): + # users[orm_user] returns User(orm_user) + orm_user = key + if orm_user.id not in self: + user = self[orm_user.id] = User(orm_user, self.settings) + return user + user = dict.__getitem__(self, orm_user.id) + user.db = self.db + return user + elif isinstance(key, int): + id = key + if id not in self: + orm_user = self.db.query(orm.User).filter(orm.User.id==id).first() + if orm_user is None: + raise KeyError("No such user: %s" % id) + user = self[id] = User(orm_user, self.settings) + return dict.__getitem__(self, id) + else: + raise KeyError(repr(key)) + + def __delitem__(self, key): + user = self[key] + user_id = user.id + db = self.db + db.delete(user.orm_user) + db.commit() + dict.__delitem__(self, user_id) + + +class User(HasTraits): + + def _log_default(self): + return app_log + + settings = Dict() + + db = Any(allow_none=True) + def _db_default(self): + if self.orm_user: + return inspect(self.orm_user).session + + def _db_changed(self, name, old, new): + """Changing db session reacquires ORM User object""" + # db session changed, re-get orm User + if self.orm_user: + id = self.orm_user.id + self.orm_user = new.query(orm.User).filter(orm.User.id==id).first() + self.spawner.db = self.db + + orm_user = None + spawner = None + spawn_pending = False + stop_pending = False + + @property + def authenticator(self): + return self.settings.get('authenticator', None) + + @property + def spawner_class(self): + return self.settings.get('spawner_class', LocalProcessSpawner) + + def __init__(self, orm_user, settings, **kwargs): + self.orm_user = orm_user + self.settings = settings + super().__init__(**kwargs) + + hub = self.db.query(orm.Hub).first() + + self.cookie_name = '%s-%s' % (hub.server.cookie_name, quote(self.name, safe='')) + self.base_url = url_path_join( + self.settings.get('base_url', '/'), 'user', self.escaped_name) + + self.spawner = self.spawner_class( + user=self, + db=self.db, + hub=hub, + authenticator=self.authenticator, + config=self.settings.get('config'), + ) + + # pass get/setattr to ORM user + + def __getattr__(self, attr): + if hasattr(self.orm_user, attr): + return getattr(self.orm_user, attr) + else: + raise AttributeError(attr) + + def __setattr__(self, attr, value): + if self.orm_user and hasattr(self.orm_user, attr): + setattr(self.orm_user, attr, value) + else: + super().__setattr__(attr, value) + + def __repr__(self): + return repr(self.orm_user) + + @property + def running(self): + """property for whether a user has a running server""" + if self.spawn_pending: + return False # server is not running if spawn is still pending + if self.server is None: + return False + return True + + @property + def escaped_name(self): + """My name, escaped for use in URLs, cookies, etc.""" + return quote(self.name, safe='@') + + @property + def proxy_path(self): + if self.settings.get('subdomain_host'): + return url_path_join('/' + self.domain, self.server.base_url) + else: + return self.server.base_url + + @property + def domain(self): + """Get the domain for my server.""" + # FIXME: escaped_name probably isn't escaped enough in general for a domain fragment + return self.escaped_name + '.' + self.settings['domain'] + + @property + def host(self): + """Get the *host* for my server (domain[:port])""" + # FIXME: escaped_name probably isn't escaped enough in general for a domain fragment + parsed = urlparse(self.settings['subdomain_host']) + h = '%s://%s.%s' % (parsed.scheme, self.escaped_name, parsed.netloc) + return h + + @property + def url(self): + """My URL + + Full name.domain/path if using subdomains, otherwise just my /base/url + """ + if self.settings.get('subdomain_host'): + return '{host}{path}'.format( + host=self.host, + path=self.server.base_url, + ) + else: + return self.server.base_url + + @gen.coroutine + def spawn(self, options=None): + """Start the user's spawner""" + db = self.db + + self.server = orm.Server( + cookie_name=self.cookie_name, + base_url=self.base_url, + ) + db.add(self.server) + db.commit() + + api_token = self.new_api_token() + db.commit() + + spawner = self.spawner + spawner.user_options = options or {} + # we are starting a new server, make sure it doesn't restore state + spawner.clear_state() + spawner.api_token = api_token + + # trigger pre-spawn hook on authenticator + authenticator = self.authenticator + if (authenticator): + yield gen.maybe_future(authenticator.pre_spawn_start(self, spawner)) + + self.spawn_pending = True + # wait for spawner.start to return + try: + f = spawner.start() + # commit any changes in spawner.start (always commit db changes before yield) + db.commit() + yield gen.with_timeout(timedelta(seconds=spawner.start_timeout), f) + except Exception as e: + if isinstance(e, gen.TimeoutError): + self.log.warn("{user}'s server failed to start in {s} seconds, giving up".format( + user=self.name, s=spawner.start_timeout, + )) + e.reason = 'timeout' + else: + self.log.error("Unhandled error starting {user}'s server: {error}".format( + user=self.name, error=e, + )) + e.reason = 'error' + try: + yield self.stop() + except Exception: + self.log.error("Failed to cleanup {user}'s server that failed to start".format( + user=self.name, + ), exc_info=True) + # raise original exception + raise e + spawner.start_polling() + + # store state + self.state = spawner.get_state() + self.last_activity = datetime.utcnow() + db.commit() + self.spawn_pending = False + try: + yield self.server.wait_up(http=True, timeout=spawner.http_timeout) + except Exception as e: + if isinstance(e, TimeoutError): + self.log.warn( + "{user}'s server never showed up at {url} " + "after {http_timeout} seconds. Giving up".format( + user=self.name, + url=self.server.url, + http_timeout=spawner.http_timeout, + ) + ) + e.reason = 'timeout' + else: + e.reason = 'error' + self.log.error("Unhandled error waiting for {user}'s server to show up at {url}: {error}".format( + user=self.name, url=self.server.url, error=e, + )) + try: + yield self.stop() + except Exception: + self.log.error("Failed to cleanup {user}'s server that failed to start".format( + user=self.name, + ), exc_info=True) + # raise original TimeoutError + raise e + return self + + @gen.coroutine + def stop(self): + """Stop the user's spawner + + and cleanup after it. + """ + self.spawn_pending = False + spawner = self.spawner + self.spawner.stop_polling() + self.stop_pending = True + try: + status = yield spawner.poll() + if status is None: + yield self.spawner.stop() + spawner.clear_state() + self.state = spawner.get_state() + self.last_activity = datetime.utcnow() + self.server = None + self.db.commit() + finally: + self.stop_pending = False + # trigger post-spawner hook on authenticator + auth = spawner.authenticator + if auth: + yield gen.maybe_future( + auth.post_spawn_stop(self, spawner) + ) + diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index 03c7721d..811e7812 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -6,16 +6,17 @@ from binascii import b2a_hex import errno import hashlib +from hmac import compare_digest import os import socket +from threading import Thread import uuid +import warnings from tornado import web, gen, ioloop from tornado.httpclient import AsyncHTTPClient, HTTPError from tornado.log import app_log -from IPython.html.utils import url_path_join - def random_port(): """get a single random port""" @@ -42,7 +43,7 @@ def wait_for_server(ip, port, timeout=10): app_log.error("Unexpected error waiting for %s:%i %s", ip, port, e ) - yield gen.Task(loop.add_timeout, loop.time() + 0.1) + yield gen.sleep(0.1) else: return raise TimeoutError("Server at {ip}:{port} didn't respond in {timeout} seconds".format( @@ -68,14 +69,14 @@ def wait_for_http_server(url, timeout=10): # we expect 599 for no connection, # but 502 or other proxy error is conceivable app_log.warn("Server at %s responded with error: %s", url, e.code) - yield gen.Task(loop.add_timeout, loop.time() + 0.25) + yield gen.sleep(0.1) else: app_log.debug("Server at %s responded with %s", url, e.code) return except (OSError, socket.error) as e: if e.errno not in {errno.ECONNABORTED, errno.ECONNREFUSED, errno.ECONNRESET}: app_log.warn("Failed to connect to %s (%s)", url, e) - yield gen.Task(loop.add_timeout, loop.time() + 0.25) + yield gen.sleep(0.1) else: return @@ -165,9 +166,32 @@ def compare_token(compare, token): uses the same algorithm and salt of the hashed token for comparison """ algorithm, srounds, salt, _ = compare.split(':') - hashed = hash_token(token, salt=salt, rounds=int(srounds), algorithm=algorithm) - if compare == hashed: + hashed = hash_token(token, salt=salt, rounds=int(srounds), algorithm=algorithm).encode('utf8') + compare = compare.encode('utf8') + if compare_digest(compare, hashed): return True return False - + + +def url_path_join(*pieces): + """Join components of url into a relative url + + Use to prevent double slash when joining subpath. This will leave the + initial and final / in place + + Copied from notebook.utils.url_path_join + """ + initial = pieces[0].startswith('/') + final = pieces[-1].endswith('/') + stripped = [ s.strip('/') for s in pieces ] + result = '/'.join(s for s in stripped if s) + + if initial: + result = '/' + result + if final: + result = result + '/' + if result == '//': + result = '/' + + return result diff --git a/jupyterhub/version.py b/jupyterhub/version.py index 1944c827..92a81e49 100644 --- a/jupyterhub/version.py +++ b/jupyterhub/version.py @@ -5,8 +5,8 @@ version_info = ( 0, - 2, - 0, + 5, + 1, 'dev', ) diff --git a/readthedocs.yml b/readthedocs.yml new file mode 100644 index 00000000..fd8fa86f --- /dev/null +++ b/readthedocs.yml @@ -0,0 +1,5 @@ +name: jupyterhub +type: sphinx +requirements_file: docs/requirements.txt +python: + version: 3 diff --git a/requirements.txt b/requirements.txt index 0f42f481..58475fd3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -ipython>=3 -tornado>=4 +traitlets>=4 +tornado>=4.1 jinja2 -simplepam -sqlalchemy +pamela +sqlalchemy>=1.0 requests diff --git a/scripts/jupyterhub-singleuser b/scripts/jupyterhub-singleuser index 95374288..170d8464 100755 --- a/scripts/jupyterhub-singleuser +++ b/scripts/jupyterhub-singleuser @@ -1,4 +1,297 @@ #!/usr/bin/env python3 +"""Extend regular notebook server to be aware of multiuser things.""" -from jupyterhub.singleuser import main -main() +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import os +try: + from urllib.parse import quote +except ImportError: + # PY2 Compat + from urllib import quote + +import requests +from jinja2 import ChoiceLoader, FunctionLoader + +from tornado import ioloop +from tornado.web import HTTPError + +try: + import notebook +except ImportError: + raise ImportError("JupyterHub single-user server requires notebook >= 4.0") + +from traitlets import ( + Bool, + Integer, + Unicode, + CUnicode, +) + +from notebook.notebookapp import ( + NotebookApp, + aliases as notebook_aliases, + flags as notebook_flags, +) +from notebook.auth.login import LoginHandler +from notebook.auth.logout import LogoutHandler + +from notebook.utils import url_path_join + + +# Define two methods to attach to AuthenticatedHandler, +# which authenticate via the central auth server. + +class JupyterHubLoginHandler(LoginHandler): + @staticmethod + def login_available(settings): + return True + + @staticmethod + def verify_token(self, cookie_name, encrypted_cookie): + """method for token verification""" + cookie_cache = self.settings['cookie_cache'] + if encrypted_cookie in cookie_cache: + # we've seen this token before, don't ask upstream again + return cookie_cache[encrypted_cookie] + + hub_api_url = self.settings['hub_api_url'] + hub_api_key = self.settings['hub_api_key'] + r = requests.get(url_path_join( + hub_api_url, "authorizations/cookie", cookie_name, quote(encrypted_cookie, safe=''), + ), + headers = {'Authorization' : 'token %s' % hub_api_key}, + ) + if r.status_code == 404: + data = None + elif r.status_code == 403: + self.log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason) + raise HTTPError(500, "Permission failure checking authorization, I may need to be restarted") + elif r.status_code >= 500: + self.log.error("Upstream failure verifying auth token: [%i] %s", r.status_code, r.reason) + raise HTTPError(502, "Failed to check authorization (upstream problem)") + elif r.status_code >= 400: + self.log.warn("Failed to check authorization: [%i] %s", r.status_code, r.reason) + raise HTTPError(500, "Failed to check authorization") + else: + data = r.json() + cookie_cache[encrypted_cookie] = data + return data + + @staticmethod + def get_user(self): + """alternative get_current_user to query the central server""" + # only allow this to be called once per handler + # avoids issues if an error is raised, + # since this may be called again when trying to render the error page + if hasattr(self, '_cached_user'): + return self._cached_user + + self._cached_user = None + my_user = self.settings['user'] + encrypted_cookie = self.get_cookie(self.cookie_name) + if encrypted_cookie: + auth_data = JupyterHubLoginHandler.verify_token(self, self.cookie_name, encrypted_cookie) + if not auth_data: + # treat invalid token the same as no token + return None + user = auth_data['name'] + if user == my_user: + self._cached_user = user + return user + else: + return None + else: + self.log.debug("No token cookie") + return None + + +class JupyterHubLogoutHandler(LogoutHandler): + def get(self): + self.redirect( + self.settings['hub_host'] + + url_path_join(self.settings['hub_prefix'], 'logout')) + + +# register new hub related command-line aliases +aliases = dict(notebook_aliases) +aliases.update({ + 'user' : 'SingleUserNotebookApp.user', + 'cookie-name': 'SingleUserNotebookApp.cookie_name', + 'hub-prefix': 'SingleUserNotebookApp.hub_prefix', + 'hub-host': 'SingleUserNotebookApp.hub_host', + 'hub-api-url': 'SingleUserNotebookApp.hub_api_url', + 'base-url': 'SingleUserNotebookApp.base_url', +}) +flags = dict(notebook_flags) +flags.update({ + 'disable-user-config': ({ + 'SingleUserNotebookApp': { + 'disable_user_config': True + } + }, "Disable user-controlled configuration of the notebook server.") +}) + +page_template = """ +{% extends "templates/page.html" %} + +{% block header_buttons %} +{{super()}} + + +Control Panel +{% endblock %} +{% block logo %} +Jupyter Notebook +{% endblock logo %} +""" + +def _exclude_home(path_list): + """Filter out any entries in a path list that are in my home directory. + + Used to disable per-user configuration. + """ + home = os.path.expanduser('~') + for p in path_list: + if not p.startswith(home): + yield p + +class SingleUserNotebookApp(NotebookApp): + """A Subclass of the regular NotebookApp that is aware of the parent multiuser context.""" + user = CUnicode(config=True) + def _user_changed(self, name, old, new): + self.log.name = new + cookie_name = Unicode(config=True) + hub_prefix = Unicode(config=True) + hub_host = Unicode(config=True) + hub_api_url = Unicode(config=True) + aliases = aliases + flags = flags + open_browser = False + trust_xheaders = True + login_handler_class = JupyterHubLoginHandler + logout_handler_class = JupyterHubLogoutHandler + port_retries = 0 # disable port-retries, since the Spawner will tell us what port to use + + disable_user_config = Bool(False, config=True, + help="""Disable user configuration of single-user server. + + Prevents user-writable files that normally configure the single-user server + from being loaded, ensuring admins have full control of configuration. + """ + ) + + cookie_cache_lifetime = Integer( + config=True, + default_value=300, + allow_none=True, + help=""" + Time, in seconds, that we cache a validated cookie before requiring + revalidation with the hub. + """, + ) + + def _log_datefmt_default(self): + """Exclude date from default date format""" + return "%Y-%m-%d %H:%M:%S" + + def _log_format_default(self): + """override default log format to include time""" + return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s" + + def _confirm_exit(self): + # disable the exit confirmation for background notebook processes + ioloop.IOLoop.instance().stop() + + def _clear_cookie_cache(self): + self.log.debug("Clearing cookie cache") + self.tornado_settings['cookie_cache'].clear() + + def migrate_config(self): + if self.disable_user_config: + # disable config-migration when user config is disabled + return + else: + super(SingleUserNotebookApp, self).migrate_config() + + @property + def config_file_paths(self): + path = super(SingleUserNotebookApp, self).config_file_paths + + if self.disable_user_config: + # filter out user-writable config dirs if user config is disabled + path = list(_exclude_home(path)) + return path + + @property + def nbextensions_path(self): + path = super(SingleUserNotebookApp, self).nbextensions_path + + if self.disable_user_config: + path = list(_exclude_home(path)) + return path + + def _static_custom_path_default(self): + path = super(SingleUserNotebookApp, self)._static_custom_path_default() + if self.disable_user_config: + path = list(_exclude_home(path)) + return path + + def start(self): + # Start a PeriodicCallback to clear cached cookies. This forces us to + # revalidate our user with the Hub at least every + # `cookie_cache_lifetime` seconds. + if self.cookie_cache_lifetime: + ioloop.PeriodicCallback( + self._clear_cookie_cache, + self.cookie_cache_lifetime * 1e3, + ).start() + super(SingleUserNotebookApp, self).start() + + def init_webapp(self): + # load the hub related settings into the tornado settings dict + env = os.environ + s = self.tornado_settings + s['cookie_cache'] = {} + s['user'] = self.user + s['hub_api_key'] = env.pop('JPY_API_TOKEN') + s['hub_prefix'] = self.hub_prefix + s['hub_host'] = self.hub_host + s['cookie_name'] = self.cookie_name + s['login_url'] = self.hub_host + self.hub_prefix + s['hub_api_url'] = self.hub_api_url + s['csp_report_uri'] = self.hub_host + url_path_join(self.hub_prefix, 'security/csp-report') + super(SingleUserNotebookApp, self).init_webapp() + self.patch_templates() + + def patch_templates(self): + """Patch page templates to add Hub-related buttons""" + + self.jinja_template_vars['logo_url'] = self.hub_host + url_path_join(self.hub_prefix, 'logo') + env = self.web_app.settings['jinja2_env'] + + env.globals['hub_control_panel_url'] = \ + self.hub_host + url_path_join(self.hub_prefix, 'home') + + # patch jinja env loading to modify page template + def get_page(name): + if name == 'page.html': + return page_template + + orig_loader = env.loader + env.loader = ChoiceLoader([ + FunctionLoader(get_page), + orig_loader, + ]) + + +def main(): + return SingleUserNotebookApp.launch_instance() + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index 47631eff..a34b421b 100755 --- a/setup.py +++ b/setup.py @@ -83,8 +83,8 @@ setup_args = dict( # this will be overridden when bower is run anyway data_files = get_data_files() or ['dummy'], version = ns['__version__'], - description = """JupyterHub: A multi-user server for Jupyter notebooks""", - long_description = "", + description = "JupyterHub: A multi-user server for Jupyter notebooks", + long_description = "See https://jupyterhub.readthedocs.org for more info.", author = "Jupyter Development Team", author_email = "jupyter@googlegroups.com", url = "http://jupyter.org", diff --git a/share/jupyter/hub/static/images/jupyter.png b/share/jupyter/hub/static/images/jupyter.png new file mode 100644 index 00000000..54cc416d Binary files /dev/null and b/share/jupyter/hub/static/images/jupyter.png differ diff --git a/share/jupyter/hub/static/js/admin.js b/share/jupyter/hub/static/js/admin.js index 51b2d378..30b22a38 100644 --- a/share/jupyter/hub/static/js/admin.js +++ b/share/jupyter/hub/static/js/admin.js @@ -42,7 +42,7 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo $("th").map(function (i, th) { th = $(th); var col = th.data('sort'); - if (!col || col.length == 0) { + if (!col || col.length === 0) { return; } var order = th.find('i').hasClass('fa-sort-desc') ? 'asc':'desc'; @@ -50,7 +50,7 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo function () { resort(col, order); } - ) + ); }); $(".time-col").map(function (i, el) { @@ -161,9 +161,17 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo $("#add-user-dialog").find(".save-button").click(function () { var dialog = $("#add-user-dialog"); - var username = dialog.find(".username-input").val(); + var lines = dialog.find(".username-input").val().split('\n'); var admin = dialog.find(".admin-checkbox").prop("checked"); - api.add_user(username, {admin: admin}, { + var usernames = []; + lines.map(function (line) { + var username = line.trim(); + if (username.length) { + usernames.push(username); + } + }); + + api.add_users(usernames, {admin: admin}, { success: function () { window.location.reload(); } diff --git a/share/jupyter/hub/static/js/jhapi.js b/share/jupyter/hub/static/js/jhapi.js index 15886e1e..754460c2 100644 --- a/share/jupyter/hub/static/js/jhapi.js +++ b/share/jupyter/hub/static/js/jhapi.js @@ -72,18 +72,16 @@ define(['jquery', 'utils'], function ($, utils) { ); }; - JHAPI.prototype.add_user = function (user, userinfo, options) { + JHAPI.prototype.add_users = function (usernames, userinfo, options) { options = options || {}; + var data = update(userinfo, {usernames: usernames}); options = update(options, { type: 'POST', dataType: null, - data: JSON.stringify(userinfo) + data: JSON.stringify(data) }); - this.api_request( - utils.url_path_join('users', user), - options - ); + this.api_request('users', options); }; JHAPI.prototype.edit_user = function (user, userinfo, options) { diff --git a/share/jupyter/hub/static/less/login.less b/share/jupyter/hub/static/less/login.less index 7ef883fe..046c79ab 100644 --- a/share/jupyter/hub/static/less/login.less +++ b/share/jupyter/hub/static/less/login.less @@ -2,6 +2,13 @@ display: table; height: 80vh; + .service-login { + text-align: center; + display: table-cell; + vertical-align: middle; + margin: auto auto 20% auto; + } + form { display: table-cell; vertical-align: middle; @@ -24,7 +31,7 @@ outline-color: @jupyter-orange; } - .message { + .login_error { color: orangered; font-weight: bold; text-align: center; diff --git a/share/jupyter/hub/static/less/logout.less b/share/jupyter/hub/static/less/logout.less deleted file mode 100644 index a0803769..00000000 --- a/share/jupyter/hub/static/less/logout.less +++ /dev/null @@ -1,14 +0,0 @@ -div.logout-main { - margin: auto; - text-align: center; -} - -div.logout-main > h1 { - font-size: 400%; - line-height: normal; -} - -div.logout-main > p { - font-size: 200%; - line-height: normal; -} diff --git a/share/jupyter/hub/static/less/page.less b/share/jupyter/hub/static/less/page.less index cdc97524..e546acce 100644 --- a/share/jupyter/hub/static/less/page.less +++ b/share/jupyter/hub/static/less/page.less @@ -1,6 +1,6 @@ .jpy-logo { - height: 35px; - margin: 2px; + height: 28px; + margin-top: 6px; } #header { diff --git a/share/jupyter/hub/static/less/style.less b/share/jupyter/hub/static/less/style.less index ce866e94..d74d7efc 100644 --- a/share/jupyter/hub/static/less/style.less +++ b/share/jupyter/hub/static/less/style.less @@ -24,5 +24,4 @@ @import "./page.less"; @import "./admin.less"; @import "./error.less"; -@import "./logout.less"; @import "./login.less"; diff --git a/share/jupyter/hub/templates/admin.html b/share/jupyter/hub/templates/admin.html index 90a8b027..5e7878fe 100644 --- a/share/jupyter/hub/templates/admin.html +++ b/share/jupyter/hub/templates/admin.html @@ -22,10 +22,10 @@ {% block thead %} - {{ th("User (%i)" % users.count(), 'name') }} + {{ th("User (%i)" % users|length, 'name') }} {{ th("Admin", 'admin') }} {{ th("Last Seen", 'last_activity') }} - {{ th("Running (%i)" % running.count(), 'running', colspan=2) }} + {{ th("Running (%i)" % running|length, 'running', colspan=2) }} {% endblock thead %} @@ -73,7 +73,7 @@ {% call modal('Shutdown Hub', btn_label='Shutdown', btn_class='btn-danger shutdown-button') %} Are you sure you want to shutdown the Hub? - You can chose to leave the proxy and/or single-user servers running by unchecking the boxes below: + You can choose to leave the proxy and/or single-user servers running by unchecking the boxes below:
{% endcall %} -{% macro user_modal(name) %} +{% macro user_modal(name, multi=False) %} {% call modal(name, btn_class='btn-primary save-button') %}
- + <{%- if multi -%} + textarea + {%- else -%} + input type="text" + {%- endif %} + class="form-control username-input" + placeholder="{%- if multi -%} usernames separated by lines{%- else -%} username {%-endif-%}"> + {%- if multi -%}{%- endif -%}