diff --git a/integration-tests/conftest.py b/integration-tests/conftest.py index 123be8d..b48e032 100644 --- a/integration-tests/conftest.py +++ b/integration-tests/conftest.py @@ -25,5 +25,5 @@ def preserve_config(request): f.write(save_config) elif os.path.exists(CONFIG_FILE): os.remove(CONFIG_FILE) - reload_component("hub") reload_component("proxy") + reload_component("hub") diff --git a/integration-tests/test_proxy.py b/integration-tests/test_proxy.py index 6aca51e..cc9d766 100644 --- a/integration-tests/test_proxy.py +++ b/integration-tests/test_proxy.py @@ -19,9 +19,7 @@ from tljh.config import ( def send_request(url, max_sleep, validate_cert=True, username=None, password=None): - resp = None for i in range(max_sleep): - time.sleep(i) try: req = HTTPRequest( url, @@ -32,12 +30,12 @@ def send_request(url, max_sleep, validate_cert=True, username=None, password=Non follow_redirects=True, max_redirects=15, ) - resp = HTTPClient().fetch(req) - break + return HTTPClient().fetch(req) except Exception as e: + if i + 1 == max_sleep: + raise print(e) - - return resp + time.sleep(i) def test_manual_https(preserve_config): @@ -104,37 +102,51 @@ def test_extra_traefik_config(): os.makedirs(dynamic_config_dir, exist_ok=True) extra_static_config = { - "entryPoints": {"no_auth_api": {"address": "127.0.0.1:9999"}}, - "api": {"dashboard": True, "entrypoint": "no_auth_api"}, + "entryPoints": {"alsoHub": {"address": "127.0.0.1:9999"}}, } extra_dynamic_config = { - "frontends": { - "test": { - "backend": "test", - "routes": { - "rule1": {"rule": "PathPrefixStrip: /the/hub/runs/here/too"} + "http": { + "middlewares": { + "testHubStripPrefix": { + "stripPrefix": {"prefixes": ["/the/hub/runs/here/too"]} + } + }, + "routers": { + "test1": { + "rule": "PathPrefix(`/hub`)", + "entryPoints": ["alsoHub"], + "service": "test", }, - } - }, - "backends": { - # redirect to hub - "test": {"servers": {"server1": {"url": "http://127.0.0.1:15001"}}} + "test2": { + "rule": "PathPrefix(`/the/hub/runs/here/too`)", + "middlewares": ["testHubStripPrefix"], + "entryPoints": ["http"], + "service": "test", + }, + }, + "services": { + "test": { + "loadBalancer": { + # forward requests to the hub + "servers": [{"url": "http://127.0.0.1:15001"}] + } + } + }, }, } success = False for i in range(5): - time.sleep(i) try: with pytest.raises(HTTPClientError, match="HTTP 401: Unauthorized"): - # The default dashboard entrypoint requires authentication, so it should fail - req = HTTPRequest("http://127.0.0.1:8099/dashboard/", method="GET") - HTTPClient().fetch(req) + # The default api entrypoint requires authentication, so it should fail + HTTPClient().fetch("http://localhost:8099/api") success = True break - except Exception: - pass + except Exception as e: + print(e) + time.sleep(i) assert success == True @@ -153,8 +165,9 @@ def test_extra_traefik_config(): # load the extra config reload_component("proxy") + # check hub page # the new dashboard entrypoint shouldn't require authentication anymore - resp = send_request(url="http://127.0.0.1:9999/dashboard/", max_sleep=5) + resp = send_request(url="http://127.0.0.1:9999/hub/login", max_sleep=5) assert resp.code == 200 # test extra dynamic config diff --git a/setup.py b/setup.py index 12d7240..255ab75 100644 --- a/setup.py +++ b/setup.py @@ -14,11 +14,10 @@ setup( "ruamel.yaml==0.17.*", "jinja2", "pluggy==1.*", - "passlib", "backoff", "requests", "bcrypt", - "jupyterhub-traefik-proxy==0.3.*", + "jupyterhub-traefik-proxy==1.0.0b3", ], entry_points={ "console_scripts": [ diff --git a/tests/test_configurer.py b/tests/test_configurer.py index c348236..29c073a 100644 --- a/tests/test_configurer.py +++ b/tests/test_configurer.py @@ -156,8 +156,8 @@ def test_traefik_api_default(): """ c = apply_mock_config({}) - assert c.TraefikTomlProxy.traefik_api_username == "api_admin" - assert len(c.TraefikTomlProxy.traefik_api_password) == 0 + assert c.TraefikProxy.traefik_api_username == "api_admin" + assert len(c.TraefikProxy.traefik_api_password) == 0 def test_set_traefik_api(): @@ -167,8 +167,8 @@ def test_set_traefik_api(): c = apply_mock_config( {"traefik_api": {"username": "some_user", "password": "1234"}} ) - assert c.TraefikTomlProxy.traefik_api_username == "some_user" - assert c.TraefikTomlProxy.traefik_api_password == "1234" + assert c.TraefikProxy.traefik_api_username == "some_user" + assert c.TraefikProxy.traefik_api_password == "1234" def test_cull_service_default(): @@ -268,7 +268,7 @@ def test_load_secrets(tljh_dir): tljh_config = configurer.load_config() assert tljh_config["traefik_api"]["password"] == "traefik-password" c = apply_mock_config(tljh_config) - assert c.TraefikTomlProxy.traefik_api_password == "traefik-password" + assert c.TraefikProxy.traefik_api_password == "traefik-password" def test_auth_native(): diff --git a/tests/test_traefik.py b/tests/test_traefik.py index 0cb406b..f78898f 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -15,30 +15,51 @@ def test_download_traefik(tmpdir): assert (traefik_bin.stat().mode & 0o777) == 0o755 +def _read_toml(path): + """Read a toml file + + print config for debugging on failure + """ + print(path) + with open(path) as f: + toml_cfg = f.read() + print(toml_cfg) + return toml.loads(toml_cfg) + + +def _read_static_config(state_dir): + return _read_toml(os.path.join(state_dir, "traefik.toml")) + + +def _read_dynamic_config(state_dir): + return _read_toml(os.path.join(state_dir, "rules", "dynamic.toml")) + + def test_default_config(tmpdir, tljh_dir): state_dir = tmpdir.mkdir("state") traefik.ensure_traefik_config(str(state_dir)) assert state_dir.join("traefik.toml").exists() - traefik_toml = os.path.join(state_dir, "traefik.toml") - with open(traefik_toml) as f: - toml_cfg = f.read() - # print config for debugging on failure - print(config.CONFIG_FILE) - print(toml_cfg) - cfg = toml.loads(toml_cfg) - assert cfg["defaultEntryPoints"] == ["http"] - assert len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1 - # runtime generated entry, value not testable - cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""] + os.path.join(state_dir, "traefik.toml") + rules_dir = os.path.join(state_dir, "rules") + cfg = _read_static_config(state_dir) + assert cfg["api"] == {} assert cfg["entryPoints"] == { - "http": {"address": ":80"}, + "http": { + "address": ":80", + "transport": {"respondingTimeouts": {"idleTimeout": "10m"}}, + }, "auth_api": { - "address": "127.0.0.1:8099", - "auth": {"basic": {"users": [""]}}, - "whiteList": {"sourceRange": ["127.0.0.1"]}, + "address": "localhost:8099", }, } + assert cfg["providers"] == { + "providersThrottleDuration": "0s", + "file": {"directory": rules_dir, "watch": True}, + } + + dynamic_config = _read_dynamic_config(state_dir) + assert dynamic_config == {} def test_letsencrypt_config(tljh_dir): @@ -51,34 +72,55 @@ def test_letsencrypt_config(tljh_dir): config.CONFIG_FILE, "https.letsencrypt.domains", ["testing.jovyan.org"] ) traefik.ensure_traefik_config(str(state_dir)) - traefik_toml = os.path.join(state_dir, "traefik.toml") - with open(traefik_toml) as f: - toml_cfg = f.read() - # print config for debugging on failure - print(config.CONFIG_FILE) - print(toml_cfg) - cfg = toml.loads(toml_cfg) - assert cfg["defaultEntryPoints"] == ["http", "https"] - assert "acme" in cfg - assert len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1 - # runtime generated entry, value not testable - cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""] + cfg = _read_static_config(state_dir) assert cfg["entryPoints"] == { - "http": {"address": ":80", "redirect": {"entryPoint": "https"}}, - "https": {"address": ":443", "tls": {"minVersion": "VersionTLS12"}}, + "http": { + "address": ":80", + "http": { + "redirections": { + "entryPoint": { + "scheme": "https", + "to": "https", + }, + }, + }, + "transport": {"respondingTimeouts": {"idleTimeout": "10m"}}, + }, + "https": { + "address": ":443", + "http": {"tls": {"options": "default"}}, + "transport": {"respondingTimeouts": {"idleTimeout": "10m"}}, + }, "auth_api": { - "address": "127.0.0.1:8099", - "auth": {"basic": {"users": [""]}}, - "whiteList": {"sourceRange": ["127.0.0.1"]}, + "address": "localhost:8099", }, } - assert cfg["acme"] == { + assert "tls" not in cfg + + dynamic_config = _read_dynamic_config(state_dir) + + assert dynamic_config["tls"] == { + "options": {"default": {"minVersion": "VersionTLS12"}}, + "stores": { + "default": { + "defaultGeneratedCert": { + "resolver": "letsencrypt", + "domain": { + "main": "testing.jovyan.org", + "sans": [], + }, + } + } + }, + } + assert "certificateResolvers" in cfg + assert "letsencrypt" in cfg["certificateResolvers"] + + assert cfg["certificateResolvers"]["letsencrypt"]["acme"] == { "email": "fake@jupyter.org", "storage": "acme.json", - "entryPoint": "https", "httpChallenge": {"entryPoint": "http"}, - "domains": [{"main": "testing.jovyan.org"}], } @@ -88,33 +130,50 @@ def test_manual_ssl_config(tljh_dir): config.set_config_value(config.CONFIG_FILE, "https.tls.key", "/path/to/ssl.key") config.set_config_value(config.CONFIG_FILE, "https.tls.cert", "/path/to/ssl.cert") traefik.ensure_traefik_config(str(state_dir)) - traefik_toml = os.path.join(state_dir, "traefik.toml") - with open(traefik_toml) as f: - toml_cfg = f.read() - # print config for debugging on failure - print(config.CONFIG_FILE) - print(toml_cfg) - cfg = toml.loads(toml_cfg) - assert cfg["defaultEntryPoints"] == ["http", "https"] - assert "acme" not in cfg - assert len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1 - # runtime generated entry, value not testable - cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""] + + cfg = _read_static_config(state_dir) + assert cfg["entryPoints"] == { - "http": {"address": ":80", "redirect": {"entryPoint": "https"}}, - "https": { - "address": ":443", - "tls": { - "minVersion": "VersionTLS12", - "certificates": [ - {"certFile": "/path/to/ssl.cert", "keyFile": "/path/to/ssl.key"} - ], + "http": { + "address": ":80", + "http": { + "redirections": { + "entryPoint": { + "scheme": "https", + "to": "https", + }, + }, + }, + "transport": { + "respondingTimeouts": { + "idleTimeout": "10m", + } }, }, + "https": { + "address": ":443", + "http": {"tls": {"options": "default"}}, + "transport": {"respondingTimeouts": {"idleTimeout": "10m"}}, + }, "auth_api": { - "address": "127.0.0.1:8099", - "auth": {"basic": {"users": [""]}}, - "whiteList": {"sourceRange": ["127.0.0.1"]}, + "address": "localhost:8099", + }, + } + assert "tls" not in cfg + + dynamic_config = _read_dynamic_config(state_dir) + + assert "tls" in dynamic_config + + assert dynamic_config["tls"] == { + "options": {"default": {"minVersion": "VersionTLS12"}}, + "stores": { + "default": { + "defaultCertificate": { + "certFile": "/path/to/ssl.cert", + "keyFile": "/path/to/ssl.key", + } + } }, } @@ -131,18 +190,18 @@ def test_extra_config(tmpdir, tljh_dir): toml_cfg = toml.load(traefik_toml) # Make sure the defaults are what we expect - assert toml_cfg["logLevel"] == "INFO" + assert toml_cfg["log"]["level"] == "INFO" with pytest.raises(KeyError): - toml_cfg["checkNewVersion"] - assert toml_cfg["entryPoints"]["auth_api"]["address"] == "127.0.0.1:8099" + toml_cfg["api"]["dashboard"] + assert toml_cfg["entryPoints"]["auth_api"]["address"] == "localhost:8099" extra_config = { # modify existing value - "logLevel": "ERROR", - # modify existing value with multiple levels - "entryPoints": {"auth_api": {"address": "127.0.0.1:9999"}}, + "log": { + "level": "ERROR", + }, # add new setting - "checkNewVersion": False, + "api": {"dashboard": True}, } with open(os.path.join(extra_config_dir, "extra.toml"), "w+") as extra_config_file: @@ -155,6 +214,5 @@ def test_extra_config(tmpdir, tljh_dir): toml_cfg = toml.load(traefik_toml) # Check that the defaults were updated by the extra config - assert toml_cfg["logLevel"] == "ERROR" - assert toml_cfg["checkNewVersion"] == False - assert toml_cfg["entryPoints"]["auth_api"]["address"] == "127.0.0.1:9999" + assert toml_cfg["log"]["level"] == "ERROR" + assert toml_cfg["api"]["dashboard"] == True diff --git a/tljh/config.py b/tljh/config.py index 4459621..60d5cc6 100644 --- a/tljh/config.py +++ b/tljh/config.py @@ -249,8 +249,11 @@ def check_hub_ready(): r = requests.get( "http://127.0.0.1:%d%s/hub/api" % (http_port, base_url), verify=False ) + if r.status_code != 200: + print(f"Hub not ready: (HTTP status {r.status_code})") return r.status_code == 200 - except: + except Exception as e: + print(f"Hub not ready: {e}") return False diff --git a/tljh/configurer.py b/tljh/configurer.py index e5c2ea2..c5ddf18 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -239,8 +239,13 @@ def update_traefik_api(c, config): """ Set traefik api endpoint credentials """ - c.TraefikTomlProxy.traefik_api_username = config["traefik_api"]["username"] - c.TraefikTomlProxy.traefik_api_password = config["traefik_api"]["password"] + c.TraefikProxy.traefik_api_username = config["traefik_api"]["username"] + c.TraefikProxy.traefik_api_password = config["traefik_api"]["password"] + https = config["https"] + if https["enabled"]: + c.TraefikProxy.traefik_entrypoint = "https" + else: + c.TraefikProxy.traefik_entrypoint = "http" def set_cull_idle_service(config): diff --git a/tljh/jupyterhub_config.py b/tljh/jupyterhub_config.py index c61c06c..7abb617 100644 --- a/tljh/jupyterhub_config.py +++ b/tljh/jupyterhub_config.py @@ -5,13 +5,12 @@ JupyterHub config for the littlest jupyterhub. import os from glob import glob -from jupyterhub_traefik_proxy import TraefikTomlProxy - from tljh import configurer from tljh.config import CONFIG_DIR, INSTALL_PREFIX, USER_ENV_PREFIX from tljh.user_creating_spawner import UserCreatingSpawner from tljh.utils import get_plugin_manager +c = get_config() # noqa c.JupyterHub.spawner_class = UserCreatingSpawner # leave users running when the Hub restarts @@ -20,11 +19,11 @@ c.JupyterHub.cleanup_servers = False # Use a high port so users can try this on machines with a JupyterHub already present c.JupyterHub.hub_port = 15001 -c.TraefikTomlProxy.should_start = False +c.TraefikProxy.should_start = False dynamic_conf_file_path = os.path.join(INSTALL_PREFIX, "state", "rules", "rules.toml") -c.TraefikTomlProxy.toml_dynamic_config_file = dynamic_conf_file_path -c.JupyterHub.proxy_class = TraefikTomlProxy +c.TraefikFileProviderProxy.dynamic_config_file = dynamic_conf_file_path +c.JupyterHub.proxy_class = "traefik_file" c.SystemdSpawner.extra_paths = [os.path.join(USER_ENV_PREFIX, "bin")] c.SystemdSpawner.default_shell = "/bin/bash" diff --git a/tljh/traefik-dynamic.toml.tpl b/tljh/traefik-dynamic.toml.tpl new file mode 100644 index 0000000..b7e96d2 --- /dev/null +++ b/tljh/traefik-dynamic.toml.tpl @@ -0,0 +1,25 @@ +# traefik.toml dynamic config (mostly TLS) +# dynamic config in the static config file will be ignored +{% if https['enabled'] %} +[tls] + [tls.options.default] + minVersion = "VersionTLS12" + + {% if https['tls']['cert'] %} + [tls.stores.default.defaultCertificate] + certFile = "{{ https['tls']['cert'] }}" + keyFile = "{{ https['tls']['key'] }}" + {% endif %} + + {% if https['letsencrypt']['email'] and https['letsencrypt']['domains'] %} + [tls.stores.default.defaultGeneratedCert] + resolver = "letsencrypt" + [tls.stores.default.defaultGeneratedCert.domain] + main = "{{ https['letsencrypt']['domains'][0] }}" + sans = [ + {% for domain in https['letsencrypt']['domains'][1:] %} + "{{ domain }}", + {% endfor %} + ] + {% endif %} +{% endif %} diff --git a/tljh/traefik.py b/tljh/traefik.py index e5aae6e..ca2b2b8 100644 --- a/tljh/traefik.py +++ b/tljh/traefik.py @@ -5,13 +5,13 @@ import logging import os import tarfile from glob import glob +from pathlib import Path from subprocess import run import backoff import requests import toml from jinja2 import Template -from passlib.apache import HtpasswdFile from tljh.configurer import _merge_dictionaries, load_config @@ -35,6 +35,8 @@ checksums = { "linux_arm64": "0a65ead411307669916ba629fa13f698acda0b2c5387abe0309b43e168e4e57f", } +_tljh_path = Path(__file__).parent.resolve() + def checksum_file(path_or_file): """Compute the sha256 checksum of a path""" @@ -58,11 +60,14 @@ def check_traefik_version(traefik_bin): """Check the traefik version from `traefik version` output""" try: - version_out = run([traefik_bin, "version"], capture=True, text=True) + version_out = run( + [traefik_bin, "version"], + capture_output=True, + text=True, + ).stdout except (FileNotFoundError, OSError) as e: logger.debug(f"Failed to get traefik version: {e}") return False - versions = {} for line in version_out.splitlines(): before, _, after = line.partition(":") key = before.strip() @@ -118,15 +123,6 @@ def ensure_traefik_binary(prefix): os.chmod(traefik_bin, 0o755) -def compute_basic_auth(username, password): - """Generate hashed HTTP basic auth from traefik_api username+password""" - ht = HtpasswdFile() - # generate htpassword - ht.set_password(username, password) - hashed_password = str(ht.to_string()).split(":")[1][:-3] - return username + ":" + hashed_password - - def load_extra_config(extra_config_dir): extra_configs = sorted(glob(os.path.join(extra_config_dir, "*.toml"))) # Load the toml list of files into dicts and merge them @@ -139,16 +135,13 @@ def ensure_traefik_config(state_dir): traefik_std_config_file = os.path.join(state_dir, "traefik.toml") traefik_extra_config_dir = os.path.join(CONFIG_DIR, "traefik_config.d") traefik_dynamic_config_dir = os.path.join(state_dir, "rules") - - config = load_config() - config["traefik_api"]["basic_auth"] = compute_basic_auth( - config["traefik_api"]["username"], - config["traefik_api"]["password"], + traefik_dynamic_config_file = os.path.join( + traefik_dynamic_config_dir, "dynamic.toml" ) - with open(os.path.join(os.path.dirname(__file__), "traefik.toml.tpl")) as f: - template = Template(f.read()) - std_config = template.render(config) + config = load_config() + config["traefik_dynamic_config_dir"] = traefik_dynamic_config_dir + https = config["https"] letsencrypt = https["letsencrypt"] tls = https["tls"] @@ -163,6 +156,14 @@ def ensure_traefik_config(state_dir): ): raise ValueError("Both email and domains must be set for letsencrypt") + with (_tljh_path / "traefik.toml.tpl").open() as f: + template = Template(f.read()) + std_config = template.render(config) + + with (_tljh_path / "traefik-dynamic.toml.tpl").open() as f: + dynamic_template = Template(f.read()) + dynamic_config = dynamic_template.render(config) + # Ensure traefik extra static config dir exists and is private os.makedirs(traefik_extra_config_dir, mode=0o700, exist_ok=True) @@ -181,6 +182,12 @@ def ensure_traefik_config(state_dir): os.fchmod(f.fileno(), 0o600) toml.dump(new_toml, f) + with open(os.path.join(traefik_dynamic_config_dir, "dynamic.toml"), "w") as f: + os.fchmod(f.fileno(), 0o600) + # validate toml syntax before writing + toml.loads(dynamic_config) + f.write(dynamic_config) + with open(os.path.join(traefik_dynamic_config_dir, "rules.toml"), "w") as f: os.fchmod(f.fileno(), 0o600) diff --git a/tljh/traefik.toml.tpl b/tljh/traefik.toml.tpl index 4364c16..eb6a8ee 100644 --- a/tljh/traefik.toml.tpl +++ b/tljh/traefik.toml.tpl @@ -1,74 +1,59 @@ -# traefik.toml file template -{% if https['enabled'] %} -defaultEntryPoints = ["http", "https"] -{% else %} -defaultEntryPoints = ["http"] -{% endif %} +# traefik.toml static config file template +# dynamic config (e.g. TLS) goes in traefik-dynamic.toml.tpl + +# enable API +[api] + +[log] +level = "INFO" -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 %} + address = ":{{ http['port'] }}" + [entryPoints.http.transport.respondingTimeouts] + idleTimeout = "10m" {% if https['enabled'] %} + [entryPoints.http.http.redirections.entryPoint] + to = "https" + scheme = "https" + [entryPoints.https] - address = ":{{https['port']}}" - [entryPoints.https.tls] - minVersion = "VersionTLS12" - {% if https['tls']['cert'] %} - [[entryPoints.https.tls.certificates]] - certFile = "{{https['tls']['cert']}}" - keyFile = "{{https['tls']['key']}}" - {% endif %} + address = ":{{ https['port'] }}" + [entryPoints.https.http.tls] + options = "default" + + [entryPoints.https.transport.respondingTimeouts] + idleTimeout = "10m" {% endif %} + [entryPoints.auth_api] - address = "127.0.0.1:{{traefik_api['port']}}" - [entryPoints.auth_api.whiteList] - sourceRange = ['{{traefik_api['ip']}}'] - [entryPoints.auth_api.auth.basic] - users = ['{{ traefik_api['basic_auth'] }}'] + address = "localhost:{{ traefik_api['port'] }}" -[wss] -protocol = "http" - -[api] -dashboard = true -entrypoint = "auth_api" - -{% if https['enabled'] and https['letsencrypt']['email'] %} -[acme] -email = "{{https['letsencrypt']['email']}}" +{% if https['enabled'] and https['letsencrypt']['email'] and https['letsencrypt']['domains'] %} +[certificateResolvers.letsencrypt.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 %} +[certificateResolvers.letsencrypt.acme.httpChallenge] +entryPoint = "http" {% endif %} -[file] -directory = "rules" +[providers] +providersThrottleDuration = "0s" + +[providers.file] +directory = "{{ traefik_dynamic_config_dir }}" watch = true