From 2cb7d10ac77b618019cac8c3d1c9ec15fbfd0709 Mon Sep 17 00:00:00 2001 From: Min RK Date: Sat, 21 Jul 2018 00:18:58 -0700 Subject: [PATCH 1/9] add load_config top-level function to configurer for easy loading of the full config --- tljh/configurer.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tljh/configurer.py b/tljh/configurer.py index 4863204..ddc7a9d 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -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 = { @@ -32,6 +39,19 @@ default = { } +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 From 000ac05e14b8342a126e995933023d6cc45affb0 Mon Sep 17 00:00:00 2001 From: Min RK Date: Sat, 21 Jul 2018 00:20:29 -0700 Subject: [PATCH 2/9] 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" + From fcdce11cae044df7bc84c6cc6c358ffd770fff29 Mon Sep 17 00:00:00 2001 From: Min RK Date: Sun, 22 Jul 2018 23:56:58 -0700 Subject: [PATCH 3/9] ensure state dir exists before putting files in it --- tljh/installer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tljh/installer.py b/tljh/installer.py index 8b272d3..219fcf6 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -112,6 +112,9 @@ def ensure_jupyterhub_service(prefix): """ 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() @@ -133,8 +136,6 @@ def ensure_jupyterhub_service(prefix): 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): From c2d6cb38cdd97bdc1f7762c17a6aace78c4dc1f4 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 30 Jul 2018 14:26:17 +0200 Subject: [PATCH 4/9] include traefik template in MANIFEST so it gets installed --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index 3a88b27..1f147fa 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ include tljh/systemd-units/* +include tljh/*.tpl From 581b4969f59b8e1cf37e8cca342a0a6bcb4e02bb Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 30 Jul 2018 14:33:02 +0200 Subject: [PATCH 5/9] traefik can give 502 while starting up --- tljh/installer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tljh/installer.py b/tljh/installer.py index 219fcf6..4c38b1e 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -262,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 From 7f07bfbec4c82dec124fb92b450bd4db5e6222f2 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 30 Jul 2018 15:00:59 +0200 Subject: [PATCH 6/9] reload traefik on `tljh-config reload proxy` --- tljh/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tljh/config.py b/tljh/config.py index 8398465..f1a146d 100644 --- a/tljh/config.py +++ b/tljh/config.py @@ -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() \ No newline at end of file + main() From 8e75a44502ec905cc0e439435f3284bca0f3d5cc Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 30 Jul 2018 15:26:09 +0200 Subject: [PATCH 7/9] grant traefik write access to state/acme.json and ensure the file exists and is private before launching --- tljh/systemd-units/traefik.service | 3 ++- tljh/traefik.py | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tljh/systemd-units/traefik.service b/tljh/systemd-units/traefik.service index 48dd9d7..01da967 100644 --- a/tljh/systemd-units/traefik.service +++ b/tljh/systemd-units/traefik.service @@ -7,13 +7,14 @@ After=network.target [Service] User=root Restart=always -# process only needs to write acme.json file, no other files +# 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 diff --git a/tljh/traefik.py b/tljh/traefik.py index 0c7c1db..cf10ecc 100644 --- a/tljh/traefik.py +++ b/tljh/traefik.py @@ -77,3 +77,8 @@ def ensure_traefik_config(state_dir): 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) + From ad3b08abd807995eb3f9ee0fb02497604bea2191 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 30 Jul 2018 15:40:10 +0200 Subject: [PATCH 8/9] traefik: only log errors and redact headers --- tljh/traefik.toml.tpl | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tljh/traefik.toml.tpl b/tljh/traefik.toml.tpl index 5aa537a..40f71b4 100644 --- a/tljh/traefik.toml.tpl +++ b/tljh/traefik.toml.tpl @@ -10,7 +10,14 @@ logLevel = "INFO" [accessLog] format = "json" [accessLog.filters] -status = ["500-999"] +statusCodes = ["500-999"] + +[accessLog.fields.headers] +[accessLog.fields.headers.names] +Authorization = "redact" +Cookie = "redact" +Set-Cookie = "redact" +X-Xsrftoken = "redact" [respondingTimeouts] idleTimeout = "10m0s" From 5a76d036f10d61c03023e58eeaea944b9459c7e7 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 30 Jul 2018 16:08:47 +0200 Subject: [PATCH 9/9] docs for setting up HTTPS --- docs/howto/https.rst | 79 ++++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 4 +++ 2 files changed, 83 insertions(+) create mode 100644 docs/howto/https.rst diff --git a/docs/howto/https.rst b/docs/howto/https.rst new file mode 100644 index 0000000..5609a42 --- /dev/null +++ b/docs/howto/https.rst @@ -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 `_, +or setting it up :ref:`manually ` with your own TLS key and certificate. +If you don't know how to do that, +then :ref:`Let's Encrypt ` 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. diff --git a/docs/index.rst b/docs/index.rst index 0196d31..be72879 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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