Merge pull request #67 from minrk/traefik

Add HTTPS support with traefik
This commit is contained in:
Yuvi Panda
2018-07-30 11:19:28 -07:00
committed by GitHub
12 changed files with 328 additions and 22 deletions

View File

@@ -1 +1,2 @@
include tljh/systemd-units/*
include tljh/*.tpl

79
docs/howto/https.rst Normal file
View 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.

View File

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

View File

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

View File

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

View File

@@ -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,19 +26,46 @@ 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):
"""
Merge config_overrides with config defaults & apply to JupyterHub config c

View File

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

View File

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

View File

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

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