mirror of
https://github.com/jupyterhub/the-littlest-jupyterhub.git
synced 2025-12-18 21:54:05 +08:00
Merge pull request #67 from minrk/traefik
Add HTTPS support with traefik
This commit is contained in:
@@ -1 +1,2 @@
|
||||
include tljh/systemd-units/*
|
||||
include tljh/*.tpl
|
||||
|
||||
79
docs/howto/https.rst
Normal file
79
docs/howto/https.rst
Normal file
@@ -0,0 +1,79 @@
|
||||
.. _howto/https:
|
||||
|
||||
============
|
||||
Enable HTTPS
|
||||
============
|
||||
|
||||
Every JupyterHub deployment should enable HTTPS!
|
||||
HTTPS encrypts traffic so that usernames and passwords and other potentially sensitive bits of information are communicated securely.
|
||||
The Littlest JupyterHub supports automatically configuring HTTPS via `Let's Encrypt <https://letsencrypt.org>`_,
|
||||
or setting it up :ref:`manually <manual_https>` with your own TLS key and certificate.
|
||||
If you don't know how to do that,
|
||||
then :ref:`Let's Encrypt <letsencrypt>` is probably the right path for you.
|
||||
|
||||
|
||||
.. _letsencrypt:
|
||||
|
||||
Automatic HTTPS with Let's Encrypt
|
||||
==================================
|
||||
|
||||
To enable HTTPS via letsencrypt::
|
||||
|
||||
sudo -E tljh-config set https.enabled true
|
||||
sudo -E tljh-config set https.letsencrypt.email you@example.com
|
||||
sudo -E tljh-config add-item https.letsencrypt.domains yourhub.yourdomain.edu
|
||||
|
||||
where ``you@example.com`` is your email address and ``yourhub.yourdomain.edu`` is the domain where your hub will be running.
|
||||
|
||||
Once you have loaded this, your config should look like::
|
||||
|
||||
sudo -E tljh-config show
|
||||
|
||||
|
||||
.. sourcecode:: yaml
|
||||
|
||||
https:
|
||||
enabled: true
|
||||
letsencrypt:
|
||||
email: you@example.com
|
||||
domains:
|
||||
- yourhub.yourdomain.edu
|
||||
|
||||
Finally, you can reload the proxy to load the new configuration::
|
||||
|
||||
sudo -E tljh-config reload proxy
|
||||
|
||||
At this point, the proxy should negotiate with Let's Encrypt to set up a trusted HTTPS certificate for you.
|
||||
It may take a moment for the proxy to negotiate with Let's Encrypt to get your certificates, after which you can access your Hub securely at https://yourhub.yourdomain.edu.
|
||||
|
||||
.. _manual_https:
|
||||
|
||||
Manual HTTPS with existing key and certificate
|
||||
==============================================
|
||||
|
||||
You may already have an SSL key and certificate.
|
||||
If so, you can tell your deployment to use these files::
|
||||
|
||||
sudo -E tljh-config set https.enabled true
|
||||
sudo -E tljh-config set https.tls.key /etc/mycerts/mydomain.key
|
||||
sudo -E tljh-config set https.tls.cert /etc/mycerts/mydomain.cert
|
||||
|
||||
|
||||
Once you have loaded this, your config should look like::
|
||||
|
||||
sudo -E tljh-config show
|
||||
|
||||
|
||||
.. sourcecode:: yaml
|
||||
|
||||
https:
|
||||
enabled: true
|
||||
tls:
|
||||
key: /etc/mycerts/mydomain.key
|
||||
cert: /etc/mycerts/mydomain.cert
|
||||
|
||||
Finally, you can reload the proxy to load the new configuration::
|
||||
|
||||
sudo -E tljh-config reload proxy
|
||||
|
||||
and now access your Hub securely at https://yourhub.yourdomain.edu.
|
||||
@@ -35,6 +35,9 @@ Ubuntu 18.04. We have a bunch of tutorials to get you started.
|
||||
You should use this if your cloud provider does not already have a direct tutorial,
|
||||
or if you have experience setting up servers.
|
||||
|
||||
Once you are ready to run your server for real,
|
||||
it's a good idea to proceed directly to :doc:`howto/https`.
|
||||
|
||||
Tutorials
|
||||
=========
|
||||
|
||||
@@ -53,6 +56,7 @@ How-To guides answer the question 'How do I...?' for a lot of topics.
|
||||
.. toctree::
|
||||
:titlesonly:
|
||||
|
||||
howto/https
|
||||
howto/user-environment
|
||||
howto/admin-users
|
||||
howto/notebook-interfaces
|
||||
|
||||
9
setup.py
9
setup.py
@@ -12,11 +12,12 @@ setup(
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
'pyyaml==3.*',
|
||||
'ruamel.yaml==0.15.*'
|
||||
'ruamel.yaml==0.15.*',
|
||||
'jinja2',
|
||||
],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'tljh-config = tljh.config:main'
|
||||
]
|
||||
}
|
||||
'tljh-config = tljh.config:main',
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -157,6 +157,7 @@ def reload_component(component):
|
||||
print('Hub reload with new configuration complete')
|
||||
elif component == 'proxy':
|
||||
systemd.restart_service('configurable-http-proxy')
|
||||
systemd.restart_service('traefik')
|
||||
print('Proxy reload with new configuration complete')
|
||||
|
||||
|
||||
@@ -238,5 +239,6 @@ def main():
|
||||
elif args.action == 'reload':
|
||||
reload_component(args.component)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -7,6 +7,13 @@ be called many times per lifetime of a jupyterhub.
|
||||
Traitlets that modify the startup of JupyterHub should not be here.
|
||||
FIXME: A strong feeling that JSON Schema should be involved somehow.
|
||||
"""
|
||||
|
||||
import os
|
||||
import yaml
|
||||
|
||||
INSTALL_PREFIX = os.environ.get('TLJH_INSTALL_PREFIX', '/opt/tljh')
|
||||
CONFIG_FILE = os.path.join(INSTALL_PREFIX, 'config.yaml')
|
||||
|
||||
# Default configuration for tljh
|
||||
# User provided config is merged into this
|
||||
default = {
|
||||
@@ -19,17 +26,44 @@ default = {
|
||||
'users': {
|
||||
'allowed': [],
|
||||
'banned': [],
|
||||
'admin': []
|
||||
'admin': [],
|
||||
},
|
||||
'limits': {
|
||||
'memory': '1G',
|
||||
'cpu': None
|
||||
'cpu': None,
|
||||
},
|
||||
'http': {
|
||||
'port': 80,
|
||||
},
|
||||
'https': {
|
||||
'enabled': False,
|
||||
'port': 443,
|
||||
'tls': {
|
||||
'cert': '',
|
||||
'key': '',
|
||||
},
|
||||
'letsencrypt': {
|
||||
'email': '',
|
||||
'domains': [],
|
||||
},
|
||||
},
|
||||
'user_environment': {
|
||||
'default_app': 'classic'
|
||||
'default_app': 'classic',
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
def load_config(config_file=CONFIG_FILE):
|
||||
"""Load the current config as a dictionary
|
||||
|
||||
merges overrides from config.yaml with default config
|
||||
"""
|
||||
if os.path.exists(config_file):
|
||||
with open(config_file) as f:
|
||||
config_overrides = yaml.safe_load(f)
|
||||
else:
|
||||
config_overrides = {}
|
||||
return _merge_dictionaries(dict(default), config_overrides)
|
||||
|
||||
|
||||
def apply_config(config_overrides, c):
|
||||
|
||||
@@ -10,7 +10,7 @@ from urllib.request import urlopen, URLError
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from tljh import conda, systemd, user, apt
|
||||
from tljh import conda, systemd, traefik, user, apt
|
||||
|
||||
INSTALL_PREFIX = os.environ.get('TLJH_INSTALL_PREFIX', '/opt/tljh')
|
||||
HUB_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'hub')
|
||||
@@ -110,25 +110,32 @@ def ensure_chp_package(prefix):
|
||||
|
||||
def ensure_jupyterhub_service(prefix):
|
||||
"""
|
||||
Ensure JupyterHub & CHP Services are set up properly
|
||||
Ensure JupyterHub Services are set up properly
|
||||
"""
|
||||
|
||||
os.makedirs(STATE_DIR, mode=0o700, exist_ok=True)
|
||||
|
||||
with open(os.path.join(HERE, 'systemd-units', 'jupyterhub.service')) as f:
|
||||
hub_unit_template = f.read()
|
||||
|
||||
with open(os.path.join(HERE, 'systemd-units', 'configurable-http-proxy.service')) as f:
|
||||
proxy_unit_template = f.read()
|
||||
|
||||
with open(os.path.join(HERE, 'systemd-units', 'traefik.service')) as f:
|
||||
traefik_unit_template = f.read()
|
||||
|
||||
traefik.ensure_traefik_config(STATE_DIR)
|
||||
|
||||
unit_params = dict(
|
||||
python_interpreter_path=sys.executable,
|
||||
jupyterhub_config_path=os.path.join(HERE, 'jupyterhub_config.py'),
|
||||
install_prefix=INSTALL_PREFIX
|
||||
install_prefix=INSTALL_PREFIX,
|
||||
)
|
||||
systemd.install_unit('configurable-http-proxy.service', proxy_unit_template.format(**unit_params))
|
||||
systemd.install_unit('jupyterhub.service', hub_unit_template.format(**unit_params))
|
||||
systemd.install_unit('traefik.service', traefik_unit_template.format(**unit_params))
|
||||
systemd.reload_daemon()
|
||||
|
||||
os.makedirs(STATE_DIR, mode=0o700, exist_ok=True)
|
||||
|
||||
# Set up proxy / hub secret oken if it is not already setup
|
||||
proxy_secret_path = os.path.join(STATE_DIR, 'configurable-http-proxy.secret')
|
||||
if not os.path.exists(proxy_secret_path):
|
||||
@@ -141,10 +148,12 @@ def ensure_jupyterhub_service(prefix):
|
||||
systemd.start_service('configurable-http-proxy')
|
||||
# If JupyterHub is running, we want to restart it.
|
||||
systemd.restart_service('jupyterhub')
|
||||
systemd.restart_service('traefik')
|
||||
|
||||
# Mark JupyterHub & CHP to start at boot ime
|
||||
# Mark JupyterHub & CHP to start at boot time
|
||||
systemd.enable_service('jupyterhub')
|
||||
systemd.enable_service('configurable-http-proxy')
|
||||
systemd.enable_service('traefik')
|
||||
|
||||
|
||||
def ensure_jupyterhub_package(prefix):
|
||||
@@ -165,6 +174,7 @@ def ensure_jupyterhub_package(prefix):
|
||||
'jupyterhub-ldapauthenticator==1.2.2',
|
||||
'oauthenticator==0.7.3',
|
||||
])
|
||||
traefik.ensure_traefik_binary(prefix)
|
||||
|
||||
|
||||
def ensure_usergroups():
|
||||
@@ -252,7 +262,7 @@ def ensure_jupyterhub_running(times=4):
|
||||
urlopen('http://127.0.0.1')
|
||||
return
|
||||
except HTTPError as h:
|
||||
if h.code in [404, 503]:
|
||||
if h.code in [404, 502, 503]:
|
||||
# May be transient
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
@@ -29,8 +29,6 @@ class CustomSpawner(SystemdSpawner):
|
||||
|
||||
c.JupyterHub.spawner_class = CustomSpawner
|
||||
|
||||
c.JupyterHub.port = 80
|
||||
|
||||
# Use a high port so users can try this on machines with a JupyterHub already present
|
||||
c.JupyterHub.hub_port = 15001
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=root
|
||||
User=nobody
|
||||
Restart=always
|
||||
# chp process should have no write access anywhere on disk
|
||||
ProtectHome=tmpfs
|
||||
@@ -16,8 +16,8 @@ ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
EnvironmentFile={install_prefix}/state/configurable-http-proxy.secret
|
||||
ExecStart={install_prefix}/hub/node_modules/.bin/configurable-http-proxy \
|
||||
--ip 0.0.0.0 \
|
||||
--port 80 \
|
||||
--ip 127.0.0.1 \
|
||||
--port 15003 \
|
||||
--api-ip 127.0.0.1 \
|
||||
--api-port 15002 \
|
||||
--error-target http://127.0.0.1:15001/hub/error
|
||||
|
||||
24
tljh/systemd-units/traefik.service
Normal file
24
tljh/systemd-units/traefik.service
Normal file
@@ -0,0 +1,24 @@
|
||||
# Template file for Traefik systemd service
|
||||
# Uses simple string.format() for 'templating'
|
||||
[Unit]
|
||||
# Wait for network stack to be fully up before starting proxy
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=root
|
||||
Restart=always
|
||||
# process only needs to write state/acme.json file, no other files
|
||||
ProtectHome=tmpfs
|
||||
ProtectSystem=strict
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ReadWritePaths={install_prefix}/state/acme.json
|
||||
WorkingDirectory={install_prefix}/state
|
||||
ExecStart={install_prefix}/hub/bin/traefik \
|
||||
-c {install_prefix}/state/traefik.toml
|
||||
|
||||
[Install]
|
||||
# Start service when system boots
|
||||
WantedBy=multi-user.target
|
||||
84
tljh/traefik.py
Normal file
84
tljh/traefik.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Traefik installation and setup"""
|
||||
import hashlib
|
||||
import os
|
||||
from urllib.request import urlretrieve
|
||||
|
||||
from jinja2 import Environment, Template
|
||||
|
||||
from tljh.configurer import load_config
|
||||
|
||||
# FIXME: support more than one platform here
|
||||
plat = "linux-amd64"
|
||||
traefik_version = "1.6.5"
|
||||
|
||||
# record sha256 hashes for supported platforms here
|
||||
checksums = {
|
||||
"linux-amd64": "9e77c7664e316953e3f5463c323dffeeecbb35d0b1db7fb49f52e1d9464ca193"
|
||||
}
|
||||
|
||||
|
||||
def checksum_file(path):
|
||||
"""Compute the sha256 checksum of a path"""
|
||||
hasher = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
def ensure_traefik_binary(prefix):
|
||||
"""Download and install the traefik binary"""
|
||||
traefik_bin = os.path.join(prefix, "bin", "traefik")
|
||||
if os.path.exists(traefik_bin):
|
||||
checksum = checksum_file(traefik_bin)
|
||||
if checksum == checksums[plat]:
|
||||
# already have the right binary
|
||||
# ensure permissions and we're done
|
||||
os.chmod(traefik_bin, 0o755)
|
||||
return
|
||||
else:
|
||||
print(f"checksum mismatch on {traefik_bin}")
|
||||
os.remove(traefik_bin)
|
||||
|
||||
traefik_url = (
|
||||
"https://github.com/containous/traefik/releases"
|
||||
f"/download/v{traefik_version}/traefik_{plat}"
|
||||
)
|
||||
print(f"Downloading traefik {traefik_version}...")
|
||||
# download the file
|
||||
urlretrieve(traefik_url, traefik_bin)
|
||||
os.chmod(traefik_bin, 0o755)
|
||||
|
||||
# verify that we got what we expected
|
||||
checksum = checksum_file(traefik_bin)
|
||||
if checksum != checksums[plat]:
|
||||
raise IOError(f"Checksum failed {traefik_bin}: {checksum} != {checksums[plat]}")
|
||||
|
||||
|
||||
def ensure_traefik_config(state_dir):
|
||||
"""Render the traefik.toml config file"""
|
||||
config = load_config()
|
||||
with open(os.path.join(os.path.dirname(__file__), "traefik.toml.tpl")) as f:
|
||||
template = Template(f.read())
|
||||
new_toml = template.render(config)
|
||||
https = config["https"]
|
||||
letsencrypt = https["letsencrypt"]
|
||||
tls = https["tls"]
|
||||
# validate https config
|
||||
if https["enabled"]:
|
||||
if not tls["cert"] and not letsencrypt["email"]:
|
||||
raise ValueError(
|
||||
"To enable https, you must set tls.cert+key or letsencrypt.email+domains"
|
||||
)
|
||||
if (letsencrypt["email"] and not letsencrypt["domains"]) or (
|
||||
letsencrypt["domains"] and not letsencrypt["email"]
|
||||
):
|
||||
raise ValueError("Both email and domains must be set for letsencrypt")
|
||||
with open(os.path.join(state_dir, "traefik.toml"), "w") as f:
|
||||
os.fchmod(f.fileno(), 0o744)
|
||||
f.write(new_toml)
|
||||
|
||||
# ensure acme.json exists and is private
|
||||
with open(os.path.join(state_dir, "acme.json"), "a") as f:
|
||||
os.fchmod(f.fileno(), 0o600)
|
||||
|
||||
69
tljh/traefik.toml.tpl
Normal file
69
tljh/traefik.toml.tpl
Normal file
@@ -0,0 +1,69 @@
|
||||
# traefik.toml file template
|
||||
{% if https['enabled'] %}
|
||||
defaultEntryPoints = ["http", "https"]
|
||||
{% else %}
|
||||
defaultEntryPoints = ["http"]
|
||||
{% endif %}
|
||||
|
||||
logLevel = "INFO"
|
||||
# log errors, which could be proxy errors
|
||||
[accessLog]
|
||||
format = "json"
|
||||
[accessLog.filters]
|
||||
statusCodes = ["500-999"]
|
||||
|
||||
[accessLog.fields.headers]
|
||||
[accessLog.fields.headers.names]
|
||||
Authorization = "redact"
|
||||
Cookie = "redact"
|
||||
Set-Cookie = "redact"
|
||||
X-Xsrftoken = "redact"
|
||||
|
||||
[respondingTimeouts]
|
||||
idleTimeout = "10m0s"
|
||||
|
||||
[entryPoints]
|
||||
[entryPoints.http]
|
||||
address = ":{{http['port']}}"
|
||||
{% if https['enabled'] %}
|
||||
[entryPoints.http.redirect]
|
||||
entryPoint = "https"
|
||||
{% endif %}
|
||||
|
||||
{% if https['enabled'] %}
|
||||
[entryPoints.https]
|
||||
address = ":{{https['port']}}"
|
||||
backend = "jupyterhub"
|
||||
[entryPoints.https.tls]
|
||||
{% if https['tls']['cert'] %}
|
||||
[[entryPoints.https.tls.certificates]]
|
||||
certFile = "{{https['tls']['cert']}}"
|
||||
keyFile = "{{https['tls']['key']}}"
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if https['enabled'] and https['letsencrypt']['email'] %}
|
||||
[acme]
|
||||
email = "{{https['letsencrypt']['email']}}"
|
||||
storage = "acme.json"
|
||||
entryPoint = "https"
|
||||
[acme.httpChallenge]
|
||||
entryPoint = "http"
|
||||
|
||||
{% for domain in https['letsencrypt']['domains'] %}
|
||||
[[acme.domains]]
|
||||
main = "{{domain}}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
[file]
|
||||
|
||||
[frontends]
|
||||
[frontends.jupyterhub]
|
||||
backend = "jupyterhub"
|
||||
passHostHeader = true
|
||||
[backends]
|
||||
[backends.jupyterhub]
|
||||
[backends.jupyterhub.servers.chp]
|
||||
url = "http://127.0.0.1:15003"
|
||||
|
||||
Reference in New Issue
Block a user