From 000ac05e14b8342a126e995933023d6cc45affb0 Mon Sep 17 00:00:00 2001 From: Min RK Date: Sat, 21 Jul 2018 00:20:29 -0700 Subject: [PATCH] Add traefik in front of CHP introduces configuration for manual tls and letsencrypt --- setup.py | 9 ++- tljh/configurer.py | 24 ++++-- tljh/installer.py | 17 +++- tljh/jupyterhub_config.py | 2 - .../configurable-http-proxy.service | 6 +- tljh/systemd-units/traefik.service | 23 ++++++ tljh/traefik.py | 79 +++++++++++++++++++ tljh/traefik.toml.tpl | 62 +++++++++++++++ 8 files changed, 204 insertions(+), 18 deletions(-) create mode 100644 tljh/systemd-units/traefik.service create mode 100644 tljh/traefik.py create mode 100644 tljh/traefik.toml.tpl diff --git a/setup.py b/setup.py index 789d734..f50745e 100644 --- a/setup.py +++ b/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', + ], + }, ) diff --git a/tljh/configurer.py b/tljh/configurer.py index ddc7a9d..459ce81 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -26,16 +26,30 @@ 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', + }, } diff --git a/tljh/installer.py b/tljh/installer.py index d8f67ca..8b272d3 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -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,7 +110,7 @@ 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 """ with open(os.path.join(HERE, 'systemd-units', 'jupyterhub.service')) as f: hub_unit_template = f.read() @@ -118,13 +118,19 @@ def ensure_jupyterhub_service(prefix): 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) @@ -141,10 +147,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 +173,7 @@ def ensure_jupyterhub_package(prefix): 'jupyterhub-ldapauthenticator==1.2.2', 'oauthenticator==0.7.3', ]) + traefik.ensure_traefik_binary(prefix) def ensure_usergroups(): diff --git a/tljh/jupyterhub_config.py b/tljh/jupyterhub_config.py index f556a0b..f12e3ce 100644 --- a/tljh/jupyterhub_config.py +++ b/tljh/jupyterhub_config.py @@ -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 diff --git a/tljh/systemd-units/configurable-http-proxy.service b/tljh/systemd-units/configurable-http-proxy.service index 9301ac8..d782380 100644 --- a/tljh/systemd-units/configurable-http-proxy.service +++ b/tljh/systemd-units/configurable-http-proxy.service @@ -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 diff --git a/tljh/systemd-units/traefik.service b/tljh/systemd-units/traefik.service new file mode 100644 index 0000000..48dd9d7 --- /dev/null +++ b/tljh/systemd-units/traefik.service @@ -0,0 +1,23 @@ +# 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 acme.json file, no other files +ProtectHome=tmpfs +ProtectSystem=strict +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +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 diff --git a/tljh/traefik.py b/tljh/traefik.py new file mode 100644 index 0000000..0c7c1db --- /dev/null +++ b/tljh/traefik.py @@ -0,0 +1,79 @@ +"""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) diff --git a/tljh/traefik.toml.tpl b/tljh/traefik.toml.tpl new file mode 100644 index 0000000..5aa537a --- /dev/null +++ b/tljh/traefik.toml.tpl @@ -0,0 +1,62 @@ +# 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] +status = ["500-999"] + +[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" +