From 85e43ab1a76bbd17d7db2cd2d7c745c9ffa43664 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Thu, 14 May 2020 22:39:44 +0300 Subject: [PATCH 01/21] Add Let's Encrypt support --- jupyterhub_traefik_proxy/proxy.py | 72 +++++++++++++++++++++++-------- jupyterhub_traefik_proxy/toml.py | 2 + 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/jupyterhub_traefik_proxy/proxy.py b/jupyterhub_traefik_proxy/proxy.py index c8ba2886..8628431d 100644 --- a/jupyterhub_traefik_proxy/proxy.py +++ b/jupyterhub_traefik_proxy/proxy.py @@ -23,7 +23,7 @@ from subprocess import Popen from urllib.parse import urlparse -from traitlets import Any, Bool, Dict, Integer, Unicode, default +from traitlets import Any, Bool, Dict, Integer, List, Unicode, default from tornado.httpclient import AsyncHTTPClient from jupyterhub.utils import exponential_backoff, url_path_join, new_token @@ -47,15 +47,29 @@ class TraefikProxy(Proxy): ) traefik_api_validate_cert = Bool( - True, - config=True, - help="""validate SSL certificate of traefik api endpoint""", + True, config=True, help="""validate SSL certificate of traefik api endpoint""" ) traefik_log_level = Unicode("ERROR", config=True, help="""traefik's log level""") + traefik_https_port = Integer(8443, config=True, help="""https port""") + + traefik_auto_https = Bool( + False, config=True, help="""enable automatic HTTPS with Let's Encrypt""" + ) + + traefik_letsencrypt_email = Unicode(config=True, help="""Let's Encrypt email""") + + traefik_letsencrypt_domains = List(config=True, help="""domains list""") + + traefik_acme_server = Unicode(config=True, help="""the CA server to use""") + + traefik_acme_storage = Unicode( + "acme.json", config=True, help="""file used for certificates storage""" + ) + traefik_api_password = Unicode( - config=True, help="""The password for traefik api login""" + config=True, help="""the password for traefik api login""" ) @default("traefik_api_password") @@ -217,21 +231,48 @@ async def _setup_traefik_static_config(self): self.static_config = {} self.static_config["debug"] = True self.static_config["logLevel"] = self.traefik_log_level + entryPoints = {} + self.static_config["defaultentrypoints"] = ["http"] + entryPoints["http"] = {"address": ":" + str(urlparse(self.public_url).port)} + + if self.traefik_auto_https: + self.static_config["defaultentrypoints"].append("https") + + entryPoints["http"].update({"redirect": {"entrypoint": "https"}}) + + entryPoints["https"] = { + "address": ":" + str(self.traefik_https_port), + "tls": {}, + } + + acme = { + "email": self.traefik_letsencrypt_email, + "storage": self.traefik_acme_storage, + "entryPoint": "https", + "caServer": self.traefik_acme_server, + "httpChallenge": {"entryPoint": "http"}, + } + + acme["domains"] = [] + + for domain in self.traefik_letsencrypt_domains: + acme["domains"].append({"main": domain}) + + self.static_config["acme"] = acme if self.ssl_cert and self.ssl_key: - self.static_config["defaultentrypoints"] = ["https"] + entryPoints["http"] = {"redirect": {"entrypoint": "https"}} + + self.static_config["defaultentrypoints"].append("https") entryPoints["https"] = { - "address": ":" + str(urlparse(self.public_url).port), + "address": ":" + str(self.traefik_https_port), "tls": { "certificates": [ {"certFile": self.ssl_cert, "keyFile": self.ssl_key} ] }, } - else: - self.static_config["defaultentrypoints"] = ["http"] - entryPoints["http"] = {"address": ":" + str(urlparse(self.public_url).port)} auth = { "basic": { @@ -248,20 +289,17 @@ async def _setup_traefik_static_config(self): self.static_config["api"] = {"dashboard": True, "entrypoint": "auth_api"} self.static_config["wss"] = {"protocol": "http"} - def _routespec_to_traefik_path(self, routespec): path = self.validate_routespec(routespec) - if path != '/' and path.endswith('/'): - path = path.rstrip('/') + if path != "/" and path.endswith("/"): + path = path.rstrip("/") return path - def _routespec_from_traefik_path(self, routespec): - if not routespec.endswith('/'): - routespec = routespec + '/' + if not routespec.endswith("/"): + routespec = routespec + "/" return routespec - async def start(self): """Start the proxy. diff --git a/jupyterhub_traefik_proxy/toml.py b/jupyterhub_traefik_proxy/toml.py index cf11dd5c..7e59f661 100644 --- a/jupyterhub_traefik_proxy/toml.py +++ b/jupyterhub_traefik_proxy/toml.py @@ -94,6 +94,8 @@ def _clean_resources(self): try: if self.should_start: os.remove(self.toml_static_config_file) + if self.traefik_auto_https: + os.remove(self.traefik_acme_storage) os.remove(self.toml_dynamic_config_file) except: self.log.error("Failed to remove traefik's configuration files") From 46b0e2915c3bbb2254da607045d0f99b7c846f99 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Sun, 17 May 2020 15:27:49 +0300 Subject: [PATCH 02/21] Test auto_https --- .travis.yml | 7 ++++++ tests/config_files/pebble-config.json | 12 ++++++++++ tests/conftest.py | 33 +++++++++++++++++++++++++++ tests/proxytest.py | 33 +++++++++++++++++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 tests/config_files/pebble-config.json diff --git a/.travis.yml b/.travis.yml index aeeb5548..be7bff59 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,22 @@ language: python cache: - pip +addons: + hosts: + - jupyter.test env: global: - ETCDCTL_API=3 + - LEGO_CA_SERVER_NAME=pebble + - LEGO_CA_CERTIFICATES=$GOPATH/src/github.com/letsencrypt/pebble/test/certs/localhost/cert.pem # installing dependencies before_install: - pip install --upgrade setuptools pip - pip install --pre -r dev-requirements.txt --upgrade . - pip install pytest-cov + - go get -u github.com/letsencrypt/pebble/... + - cd $GOPATH/src/github.com/letsencrypt/pebble && go install ./... install: - python -m jupyterhub_traefik_proxy.install --traefik --etcd --consul --output=./bin - export PATH=$PWD/bin:$PATH diff --git a/tests/config_files/pebble-config.json b/tests/config_files/pebble-config.json new file mode 100644 index 00000000..2a12ea17 --- /dev/null +++ b/tests/config_files/pebble-config.json @@ -0,0 +1,12 @@ +{ + "pebble": { + "listenAddress": "0.0.0.0:14000", + "managementListenAddress": "0.0.0.0:15000", + "certificate": "$GOPATH/src/github.com/letsencrypt/pebble/test/certs/localhost/cert.pem", + "privateKey": "$GOPATH/src/github.com/letsencrypt/pebble/test/certs/localhost/key.pem", + "httpPort": 8000, + "tlsPort": 8443, + "ocspResponderURL": "", + "externalAccountBindingRequired": false + } +} diff --git a/tests/conftest.py b/tests/conftest.py index 6f9096ac..b763bfac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,39 @@ from jupyterhub_traefik_proxy import TraefikTomlProxy +@pytest.fixture +async def autohttps_toml_proxy(): + """Fixture returning a configured Let's Encrypt TraefikTomlProxy""" + proxy = TraefikTomlProxy( + public_url="http://127.0.0.1:8000", + traefik_api_password="admin", + traefik_api_username="api_admin", + should_start=True, + traefik_auto_https=True, + traefik_letsencrypt_email="jovyan@jupyter.test", + traefik_letsencrypt_domains=["jupyter.test"], + traefik_acme_server="https://0.0.0.0:14000/dir", + traefik_https_port=8443, + ) + + await proxy.start() + yield proxy + await proxy.stop() + + +@pytest.fixture +async def pebble(): + pebble_server = subprocess.Popen( + ["pebble", "-config", "./tests/config_files/pebble-config.json"], + stdout=None, + stderr=None, + ) + yield pebble_server + + pebble_server.kill() + pebble_server.wait() + + @pytest.fixture async def no_auth_consul_proxy(consul_no_acl): """ diff --git a/tests/proxytest.py b/tests/proxytest.py index f5c6a51b..e7dca36e 100644 --- a/tests/proxytest.py +++ b/tests/proxytest.py @@ -356,3 +356,36 @@ async def test_websockets(proxy, launch_backend): port = await websocket.recv() assert port == str(default_backend_port) + + +async def test_autohttps(autohttps_toml_proxy, pebble, launch_backend): + proxy = autohttps_toml_proxy + + routespec = "/autohttps" + target = "http://127.0.0.1:9900" + + backend_port = 9900 + launch_backend(backend_port) + + await wait_for_services([proxy.public_url, target]) + + await proxy.add_route(routespec, target, {}) + + # Test the actual routing + req = HTTPRequest(proxy.public_url + routespec, method="GET", validate_cert=False) + resp = await AsyncHTTPClient().fetch(req) + backend_response = int(resp.body.decode("utf-8")) + + # Test we were redirected to https + # https://127.0.0.1:8443/autohttps + expected_final_redirect_url = ( + "https://" + + urlparse(proxy.public_url).hostname + + ":" + + str(proxy.traefik_https_port) + + routespec + ) + assert resp.effective_url == expected_final_redirect_url + + # Test redirection to the route added + assert backend_response == backend_port From 90c27a0626334e5857aacc281af04009a7ae22dc Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Mon, 18 May 2020 11:03:17 +0300 Subject: [PATCH 03/21] Run cd in subshell --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index be7bff59..ec319b8f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,8 +15,9 @@ before_install: - pip install --upgrade setuptools pip - pip install --pre -r dev-requirements.txt --upgrade . - pip install pytest-cov + # Install pebble - go get -u github.com/letsencrypt/pebble/... - - cd $GOPATH/src/github.com/letsencrypt/pebble && go install ./... + - (cd $GOPATH/src/github.com/letsencrypt/pebble && go install ./...) install: - python -m jupyterhub_traefik_proxy.install --traefik --etcd --consul --output=./bin - export PATH=$PWD/bin:$PATH From 72edd0245fad3125e09a01482426dbd0e59c6930 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Mon, 18 May 2020 11:27:00 +0300 Subject: [PATCH 04/21] Don't override http entrypoint when redirecting --- jupyterhub_traefik_proxy/proxy.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/jupyterhub_traefik_proxy/proxy.py b/jupyterhub_traefik_proxy/proxy.py index 8628431d..7380c784 100644 --- a/jupyterhub_traefik_proxy/proxy.py +++ b/jupyterhub_traefik_proxy/proxy.py @@ -262,9 +262,10 @@ async def _setup_traefik_static_config(self): self.static_config["acme"] = acme if self.ssl_cert and self.ssl_key: - entryPoints["http"] = {"redirect": {"entrypoint": "https"}} - self.static_config["defaultentrypoints"].append("https") + + entryPoints["http"].update({"redirect": {"entrypoint": "https"}}) + entryPoints["https"] = { "address": ":" + str(self.traefik_https_port), "tls": { From a375dea928e6087be2b8427aa2836b7bcd715c26 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Mon, 18 May 2020 12:16:09 +0300 Subject: [PATCH 05/21] Cleanup in fixture --- jupyterhub_traefik_proxy/toml.py | 2 -- tests/conftest.py | 4 +++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/jupyterhub_traefik_proxy/toml.py b/jupyterhub_traefik_proxy/toml.py index 7e59f661..cf11dd5c 100644 --- a/jupyterhub_traefik_proxy/toml.py +++ b/jupyterhub_traefik_proxy/toml.py @@ -94,8 +94,6 @@ def _clean_resources(self): try: if self.should_start: os.remove(self.toml_static_config_file) - if self.traefik_auto_https: - os.remove(self.traefik_acme_storage) os.remove(self.toml_dynamic_config_file) except: self.log.error("Failed to remove traefik's configuration files") diff --git a/tests/conftest.py b/tests/conftest.py index b763bfac..d60bd02f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,12 +25,14 @@ async def autohttps_toml_proxy(): traefik_letsencrypt_email="jovyan@jupyter.test", traefik_letsencrypt_domains=["jupyter.test"], traefik_acme_server="https://0.0.0.0:14000/dir", - traefik_https_port=8443, ) await proxy.start() yield proxy await proxy.stop() + if os.path.exists("acme.json"): + os.remove("acme.json") + @pytest.fixture From b26119ccdf769ac6e8c5261e4d8f208a359e42d9 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Tue, 19 May 2020 11:13:35 +0300 Subject: [PATCH 06/21] Add docs about https --- docs/source/https.md | 53 +++++++++++++++++++++++++++++++++++++++++++ docs/source/index.rst | 3 ++- docs/source/toml.md | 4 ++-- 3 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 docs/source/https.md diff --git a/docs/source/https.md b/docs/source/https.md new file mode 100644 index 00000000..1700564e --- /dev/null +++ b/docs/source/https.md @@ -0,0 +1,53 @@ +# Enabling HTTPS + +In order to protect the data and passwords transfered over the public network, you should not run JupyterHub without enabling **HTTPS**. + +JupyterHub Traefik-Proxy supports **automatically** configuring HTTPS via [Let’s Encrypt](https://letsencrypt.org/docs/), or setting it +up **manually** with your own key and certificate. + +## How-To HTTPS for JupyterHub managed Traefik-Proxy +1. Via **Let'Encrypt**: + * Enable automatic https: + ```python + c.Proxy.traefik_auto_https=True + ``` + * Configure a HTTPS port. This is where all the traffic coming through the HTTP entrypoint will be redirected to: + ```python + c.Proxy.traefik_https_port=443 + ``` + * Set the email address used for Let's Encrypt registration: + ```python + c.Proxy.traefik_letsencrypt_email="jovyan@jupyter.test" + ``` + * Set the domain list: + ```python + c.Proxy.traefik_letsencrypt_domains=["jupyter.test"] + ``` + * Set the the CA server to be used: + ```python + c.Proxy.traefik_acme_server="https://acme-v02.api.letsencrypt.org/directory" + ``` + **Note !** + + **TraefikProxy**, supports only the most common challenge type, i.e. the [HTTP-01 ACME challenge](https://letsencrypt.org/docs/challenge-types/#http-01-challenge). + If other challenge type is needed, one could setup the proxy to be externally managed to get access to all the Traefik's configuration options (including the + ACME challenge type). + +2. **Manually**, by providing your own key and certificate: + + Providing a certificate and key can be done by configuring JupyterHub to enable SSL encryption as specified in [the docs](https://jupyterhub.readthedocs.io/en/stable/getting-started/security-basics.html?highlight=https#enabling-ssl-encryption). Example: + ```python + c.JupyterHub.ssl_key = '/path/to/my.key' + c.JupyterHub.ssl_cert = '/path/to/my.cert' + ``` + + Traefik will then redirect all HTTP traffic to the HTTPS entrypoint. The default HTTPS entrypoint port is 8843, but can be configured through: + ```python + c.Proxy.traefik_https_port=443 + ``` + +## How-To HTTPS for external Traefik-Proxy +If the proxy isn't managed by JupyterHub, HTTPS can be enabled through Traefik's static configuration file. +Checkout Traefik's documentation for: +* [Setting up ACME (Let's Encrypt) Configuration](https://docs.traefik.io/v1.7/configuration/acme/) +* [How to redirect HTTP traffic to HTTPS](https://docs.traefik.io/v1.7/user-guide/examples/#http-redirect-on-https) diff --git a/docs/source/index.rst b/docs/source/index.rst index 6d7a53d2..370b8918 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -39,11 +39,12 @@ Installation Guide Getting Started --------------- .. toctree:: - :maxdepth: 1 + :maxdepth: 2 toml etcd consul + https API Reference ------------- diff --git a/docs/source/toml.md b/docs/source/toml.md index 9502e8c9..f95cf12b 100644 --- a/docs/source/toml.md +++ b/docs/source/toml.md @@ -69,9 +69,9 @@ c.TraefikTomlProxy.toml_dynamic_config_file="/path/to/dynamic_config_filename.to --- **Note !** -**When JupyterHub starts the proxy**, it writes the static config once, then only edits the routes config file. +* **When JupyterHub starts the proxy**, it writes the static config once, then only edits the routes config file. -**When JupyterHub does not start the proxy**, the user is totally responsible for the static config and +* **When JupyterHub does not start the proxy**, the user is totally responsible for the static config and JupyterHub is responsible exclusively for the routes. --- From a5eef95a4ca067366ec0d772aa6ab57ae7320f2f Mon Sep 17 00:00:00 2001 From: Georgiana Elena Date: Tue, 19 May 2020 14:00:41 +0300 Subject: [PATCH 07/21] Update docs/source/https.md Co-authored-by: Erik Sundell --- docs/source/https.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/https.md b/docs/source/https.md index 1700564e..db5b97ee 100644 --- a/docs/source/https.md +++ b/docs/source/https.md @@ -17,7 +17,7 @@ up **manually** with your own key and certificate. ``` * Set the email address used for Let's Encrypt registration: ```python - c.Proxy.traefik_letsencrypt_email="jovyan@jupyter.test" + c.Proxy.traefik_letsencrypt_email="" ``` * Set the domain list: ```python From 114667728bb387e38cba95f3e63255e1351e1327 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Tue, 19 May 2020 14:17:34 +0300 Subject: [PATCH 08/21] Make default acme server be letsencrypt staging --- jupyterhub_traefik_proxy/proxy.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/jupyterhub_traefik_proxy/proxy.py b/jupyterhub_traefik_proxy/proxy.py index 7380c784..7c459659 100644 --- a/jupyterhub_traefik_proxy/proxy.py +++ b/jupyterhub_traefik_proxy/proxy.py @@ -62,7 +62,11 @@ class TraefikProxy(Proxy): traefik_letsencrypt_domains = List(config=True, help="""domains list""") - traefik_acme_server = Unicode(config=True, help="""the CA server to use""") + traefik_acme_server = Unicode( + "https://acme-staging-v02.api.letsencrypt.org/directory", + config=True, + help="""the CA server to use""", + ) traefik_acme_storage = Unicode( "acme.json", config=True, help="""file used for certificates storage""" From 03a203dd25c7b79f81ba5b954ad4be2692cce735 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Tue, 19 May 2020 16:10:44 +0300 Subject: [PATCH 09/21] Wait for cert aquisition --- tests/proxytest.py | 2 ++ tests/utils.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/tests/proxytest.py b/tests/proxytest.py index e7dca36e..ce372705 100644 --- a/tests/proxytest.py +++ b/tests/proxytest.py @@ -371,6 +371,8 @@ async def test_autohttps(autohttps_toml_proxy, pebble, launch_backend): await proxy.add_route(routespec, target, {}) + await utils.wait_for_certificate_aquisition(proxy.traefik_acme_storage) + # Test the actual routing req = HTTPRequest(proxy.public_url + routespec, method="GET", validate_cert=False) resp = await AsyncHTTPClient().fetch(req) diff --git a/tests/utils.py b/tests/utils.py index b4934a5c..10438207 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -32,6 +32,22 @@ async def check_host_up(ip, port): return is_open(ip, port) +async def wait_for_certificate_aquisition(cert_storage): + + async def _check_certificate_aquisition(): + with open(cert_storage, 'r') as cert_file: + cert_info= json.load(cert_file) + if cert_info["Certificates"]: + return True + return False + + with open(cert_storage, 'r+') as cert_info: + await exponential_backoff( + _check_certificate_aquisition, + "Certificate not available", + ) + + async def get_responding_backend_port(traefik_url, path): """ Check if traefik followed the configuration and routed the request to the right backend """ From 83f052108e33cd823b2d1a003bc7e11949c578b2 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Tue, 19 May 2020 16:19:13 +0300 Subject: [PATCH 10/21] Inject GOPATH into pebble config file --- tests/config_files/pebble-config.json | 4 +-- tests/conftest.py | 46 +++++++++++++++++++-------- tests/proxytest.py | 2 +- 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/tests/config_files/pebble-config.json b/tests/config_files/pebble-config.json index 2a12ea17..be4d3ff6 100644 --- a/tests/config_files/pebble-config.json +++ b/tests/config_files/pebble-config.json @@ -2,11 +2,9 @@ "pebble": { "listenAddress": "0.0.0.0:14000", "managementListenAddress": "0.0.0.0:15000", - "certificate": "$GOPATH/src/github.com/letsencrypt/pebble/test/certs/localhost/cert.pem", - "privateKey": "$GOPATH/src/github.com/letsencrypt/pebble/test/certs/localhost/key.pem", "httpPort": 8000, "tlsPort": 8443, "ocspResponderURL": "", "externalAccountBindingRequired": false } -} +} \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index d60bd02f..fc0effcf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ import sys import time +import json import pytest from jupyterhub_traefik_proxy import TraefikEtcdProxy @@ -34,20 +35,6 @@ async def autohttps_toml_proxy(): os.remove("acme.json") - -@pytest.fixture -async def pebble(): - pebble_server = subprocess.Popen( - ["pebble", "-config", "./tests/config_files/pebble-config.json"], - stdout=None, - stderr=None, - ) - yield pebble_server - - pebble_server.kill() - pebble_server.wait() - - @pytest.fixture async def no_auth_consul_proxy(consul_no_acl): """ @@ -225,6 +212,37 @@ def external_toml_proxy(): traefik_process.wait() +@pytest.fixture +async def pebble(): + config_file = "./tests/config_files/pebble-config.json" + gopath = os.environ["GOPATH"] + """ + Insert the path to certs into the pebble config file. + We cannot pass $GOPATH directly as pebble doesn't know how to parse env vars + and we cannot hard-code it either as the test would error when run locally. + """ + with open(config_file, "r+") as f: + data = json.load(f) + data["pebble"]["certificate"] = ( + gopath + "/src/github.com/letsencrypt/pebble/test/certs/localhost/cert.pem" + ) + data["pebble"]["privateKey"] = ( + gopath + "/src/github.com/letsencrypt/pebble/test/certs/localhost/key.pem" + ) + f.seek(0) + json.dump(data, f, indent=2) + + pebble_server = subprocess.Popen( + ["pebble", "-config", "./tests/config_files/pebble-config.json"], + stdout=None, + stderr=None, + ) + yield pebble_server + + pebble_server.kill() + pebble_server.wait() + + @pytest.fixture(scope="session", autouse=True) def etcd(): etcd_proc = subprocess.Popen("etcd", stdout=None, stderr=None) diff --git a/tests/proxytest.py b/tests/proxytest.py index ce372705..5c366f19 100644 --- a/tests/proxytest.py +++ b/tests/proxytest.py @@ -358,7 +358,7 @@ async def test_websockets(proxy, launch_backend): assert port == str(default_backend_port) -async def test_autohttps(autohttps_toml_proxy, pebble, launch_backend): +async def test_autohttps(pebble, autohttps_toml_proxy, launch_backend): proxy = autohttps_toml_proxy routespec = "/autohttps" From 94e8cbecfa656c310aea6748b5d871da40b3d111 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Tue, 19 May 2020 17:39:22 +0300 Subject: [PATCH 11/21] Remove traefik http->https redirection --- jupyterhub_traefik_proxy/proxy.py | 52 +++++++++++++++++-------------- tests/conftest.py | 2 +- tests/proxytest.py | 11 ------- 3 files changed, 30 insertions(+), 35 deletions(-) diff --git a/jupyterhub_traefik_proxy/proxy.py b/jupyterhub_traefik_proxy/proxy.py index 7c459659..4623f708 100644 --- a/jupyterhub_traefik_proxy/proxy.py +++ b/jupyterhub_traefik_proxy/proxy.py @@ -52,7 +52,9 @@ class TraefikProxy(Proxy): traefik_log_level = Unicode("ERROR", config=True, help="""traefik's log level""") - traefik_https_port = Integer(8443, config=True, help="""https port""") + traefik_acme_challenge_port = Integer( + 80, config=True, help="""http port for the acme challenge""" + ) traefik_auto_https = Bool( False, config=True, help="""enable automatic HTTPS with Let's Encrypt""" @@ -237,17 +239,25 @@ async def _setup_traefik_static_config(self): self.static_config["logLevel"] = self.traefik_log_level entryPoints = {} - self.static_config["defaultentrypoints"] = ["http"] - entryPoints["http"] = {"address": ":" + str(urlparse(self.public_url).port)} + scheme = urlparse(self.public_url).scheme + address = urlparse(self.public_url).netloc + port = urlparse(self.public_url).port - if self.traefik_auto_https: - self.static_config["defaultentrypoints"].append("https") + self.static_config["defaultentrypoints"] = [scheme] + + # Traefik complains if we don't provide a port + if not port: + if scheme == "http": + address += ":80" + elif scheme == "https": + address += ":443" - entryPoints["http"].update({"redirect": {"entrypoint": "https"}}) + entryPoints[scheme] = {"address": address} - entryPoints["https"] = { - "address": ":" + str(self.traefik_https_port), - "tls": {}, + if self.traefik_auto_https: + entryPoints["https"].update({"tls": {}}) + entryPoints["acme_challenge"] = { + "address": ":" + str(self.traefik_acme_challenge_port) } acme = { @@ -255,29 +265,25 @@ async def _setup_traefik_static_config(self): "storage": self.traefik_acme_storage, "entryPoint": "https", "caServer": self.traefik_acme_server, - "httpChallenge": {"entryPoint": "http"}, + "httpChallenge": {"entryPoint": "acme_challenge"}, } acme["domains"] = [] for domain in self.traefik_letsencrypt_domains: acme["domains"].append({"main": domain}) - self.static_config["acme"] = acme if self.ssl_cert and self.ssl_key: - self.static_config["defaultentrypoints"].append("https") - - entryPoints["http"].update({"redirect": {"entrypoint": "https"}}) - - entryPoints["https"] = { - "address": ":" + str(self.traefik_https_port), - "tls": { - "certificates": [ - {"certFile": self.ssl_cert, "keyFile": self.ssl_key} - ] - }, - } + entryPoints["https"].update( + { + "tls": { + "certificates": [ + {"certFile": self.ssl_cert, "keyFile": self.ssl_key} + ] + } + } + ) auth = { "basic": { diff --git a/tests/conftest.py b/tests/conftest.py index fc0effcf..f23edc3a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,7 @@ async def autohttps_toml_proxy(): """Fixture returning a configured Let's Encrypt TraefikTomlProxy""" proxy = TraefikTomlProxy( - public_url="http://127.0.0.1:8000", + public_url="https://jupyter.test:8443", traefik_api_password="admin", traefik_api_username="api_admin", should_start=True, diff --git a/tests/proxytest.py b/tests/proxytest.py index 5c366f19..2d17acf4 100644 --- a/tests/proxytest.py +++ b/tests/proxytest.py @@ -378,16 +378,5 @@ async def test_autohttps(pebble, autohttps_toml_proxy, launch_backend): resp = await AsyncHTTPClient().fetch(req) backend_response = int(resp.body.decode("utf-8")) - # Test we were redirected to https - # https://127.0.0.1:8443/autohttps - expected_final_redirect_url = ( - "https://" - + urlparse(proxy.public_url).hostname - + ":" - + str(proxy.traefik_https_port) - + routespec - ) - assert resp.effective_url == expected_final_redirect_url - # Test redirection to the route added assert backend_response == backend_port From c11de2e30263b3488664f43311980fee3513be2a Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Tue, 19 May 2020 17:40:41 +0300 Subject: [PATCH 12/21] Black reformat --- tests/utils.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/utils.py b/tests/utils.py index 10438207..866168f8 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -33,19 +33,17 @@ async def check_host_up(ip, port): async def wait_for_certificate_aquisition(cert_storage): - async def _check_certificate_aquisition(): - with open(cert_storage, 'r') as cert_file: - cert_info= json.load(cert_file) + with open(cert_storage, "r") as cert_file: + cert_info = json.load(cert_file) if cert_info["Certificates"]: return True return False - with open(cert_storage, 'r+') as cert_info: + with open(cert_storage, "r+") as cert_info: await exponential_backoff( - _check_certificate_aquisition, - "Certificate not available", - ) + _check_certificate_aquisition, "Certificate not available" + ) async def get_responding_backend_port(traefik_url, path): From 52de0af5377e92eeeac86e2e588bf9b08fa2c9e3 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Wed, 20 May 2020 11:29:07 +0300 Subject: [PATCH 13/21] Set acme challenge port --- tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/conftest.py b/tests/conftest.py index f23edc3a..e6158af9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,7 @@ async def autohttps_toml_proxy(): traefik_letsencrypt_email="jovyan@jupyter.test", traefik_letsencrypt_domains=["jupyter.test"], traefik_acme_server="https://0.0.0.0:14000/dir", + traefik_acme_challenge_port=8000 ) await proxy.start() From 8c5f996c02448698fca9e04b0e00460a699b5298 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Wed, 20 May 2020 13:30:23 +0300 Subject: [PATCH 14/21] Increate cert aquisition timeout --- tests/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils.py b/tests/utils.py index 866168f8..c4161c6a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -42,7 +42,7 @@ async def _check_certificate_aquisition(): with open(cert_storage, "r+") as cert_info: await exponential_backoff( - _check_certificate_aquisition, "Certificate not available" + _check_certificate_aquisition, "Certificate not available", timeout=30 ) From b3165f2ecaa03a1243ca8010b1a56fd65a5ca5cc Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Wed, 20 May 2020 13:30:41 +0300 Subject: [PATCH 15/21] Test cert --- dev-requirements.txt | 1 + tests/proxytest.py | 42 ++++++++++++++++++++++++++++++------------ 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index 111a8f99..053c218b 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,3 +1,4 @@ +pyOpenSSL pytest pytest-asyncio codecov diff --git a/tests/proxytest.py b/tests/proxytest.py index 2d17acf4..e2dcad3d 100644 --- a/tests/proxytest.py +++ b/tests/proxytest.py @@ -1,23 +1,26 @@ """Tests for the base traefik proxy""" -import copy -import utils -import subprocess -import sys - from contextlib import contextmanager +import copy from os.path import dirname, join, abspath -from random import randint from unittest.mock import Mock from urllib.parse import quote from urllib.parse import urlparse +from random import randint +import subprocess +import socket +import sys +import ssl +from OpenSSL import crypto +from tornado.httpclient import AsyncHTTPClient, HTTPRequest, HTTPClientError import pytest +import websockets + +import utils from jupyterhub.objects import Hub, Server from jupyterhub.user import User from jupyterhub.utils import exponential_backoff, url_path_join -from tornado.httpclient import AsyncHTTPClient, HTTPRequest, HTTPClientError -import websockets class MockApp: @@ -373,10 +376,25 @@ async def test_autohttps(pebble, autohttps_toml_proxy, launch_backend): await utils.wait_for_certificate_aquisition(proxy.traefik_acme_storage) + # Test certifcates + hostname = urlparse(proxy.public_url).hostname + port = urlparse(proxy.public_url).port + + context = ssl.SSLContext() + with ssl.create_connection((hostname, port)) as connection: + with context.wrap_socket(connection, server_hostname=hostname) as sock: + certificate = ssl.DER_cert_to_PEM_cert(sock.getpeercert(True)) + cert = crypto.load_certificate(crypto.FILETYPE_PEM, certificate) + + # The signer of the server's certificate. + issuer = cert.get_issuer().commonName + assert "Pebble" in issuer + + # # The subject of this certificate signing request. + subject = cert.get_subject().commonName + assert hostname == subject + # Test the actual routing req = HTTPRequest(proxy.public_url + routespec, method="GET", validate_cert=False) resp = await AsyncHTTPClient().fetch(req) - backend_response = int(resp.body.decode("utf-8")) - - # Test redirection to the route added - assert backend_response == backend_port + assert backend_port == int(resp.body.decode("utf-8")) From af47a02c58e24f65f9ddcb09364d3af4ac8485ed Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Thu, 21 May 2020 12:06:53 +0300 Subject: [PATCH 16/21] Remove info about https port from docs --- docs/source/https.md | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/docs/source/https.md b/docs/source/https.md index db5b97ee..0a1cd7ab 100644 --- a/docs/source/https.md +++ b/docs/source/https.md @@ -11,10 +11,6 @@ up **manually** with your own key and certificate. ```python c.Proxy.traefik_auto_https=True ``` - * Configure a HTTPS port. This is where all the traffic coming through the HTTP entrypoint will be redirected to: - ```python - c.Proxy.traefik_https_port=443 - ``` * Set the email address used for Let's Encrypt registration: ```python c.Proxy.traefik_letsencrypt_email="" @@ -27,6 +23,10 @@ up **manually** with your own key and certificate. ```python c.Proxy.traefik_acme_server="https://acme-v02.api.letsencrypt.org/directory" ``` + * Set the port to be used by Traefik for the Acme HTTP challenge: + ```python + c.Proxy.traefik_acme_challenge_port="https://acme-v02.api.letsencrypt.org/directory" + ``` **Note !** **TraefikProxy**, supports only the most common challenge type, i.e. the [HTTP-01 ACME challenge](https://letsencrypt.org/docs/challenge-types/#http-01-challenge). @@ -41,13 +41,6 @@ up **manually** with your own key and certificate. c.JupyterHub.ssl_cert = '/path/to/my.cert' ``` - Traefik will then redirect all HTTP traffic to the HTTPS entrypoint. The default HTTPS entrypoint port is 8843, but can be configured through: - ```python - c.Proxy.traefik_https_port=443 - ``` - ## How-To HTTPS for external Traefik-Proxy If the proxy isn't managed by JupyterHub, HTTPS can be enabled through Traefik's static configuration file. -Checkout Traefik's documentation for: -* [Setting up ACME (Let's Encrypt) Configuration](https://docs.traefik.io/v1.7/configuration/acme/) -* [How to redirect HTTP traffic to HTTPS](https://docs.traefik.io/v1.7/user-guide/examples/#http-redirect-on-https) +Checkout Traefik's documentation for [setting up ACME (Let's Encrypt) configuration](https://docs.traefik.io/v1.7/configuration/acme/) \ No newline at end of file From 04922606064edd6737be22821e6442848c535ac2 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Thu, 21 May 2020 12:18:19 +0300 Subject: [PATCH 17/21] Fix challenge port instructions --- docs/source/https.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/source/https.md b/docs/source/https.md index 0a1cd7ab..b76401cb 100644 --- a/docs/source/https.md +++ b/docs/source/https.md @@ -25,7 +25,8 @@ up **manually** with your own key and certificate. ``` * Set the port to be used by Traefik for the Acme HTTP challenge: ```python - c.Proxy.traefik_acme_challenge_port="https://acme-v02.api.letsencrypt.org/directory" + # default port is 80 + c.Proxy.traefik_acme_challenge_port=8000 ``` **Note !** From 9d450636cb8bb996ffdbd7f8f7b814b294737fd5 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Thu, 21 May 2020 13:28:57 +0300 Subject: [PATCH 18/21] Add note about the traefik acme challenge port limitation --- docs/source/https.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/source/https.md b/docs/source/https.md index b76401cb..5c0bcd2b 100644 --- a/docs/source/https.md +++ b/docs/source/https.md @@ -23,17 +23,22 @@ up **manually** with your own key and certificate. ```python c.Proxy.traefik_acme_server="https://acme-v02.api.letsencrypt.org/directory" ``` - * Set the port to be used by Traefik for the Acme HTTP challenge: - ```python - # default port is 80 - c.Proxy.traefik_acme_challenge_port=8000 - ``` - **Note !** + **Note 1** **TraefikProxy**, supports only the most common challenge type, i.e. the [HTTP-01 ACME challenge](https://letsencrypt.org/docs/challenge-types/#http-01-challenge). If other challenge type is needed, one could setup the proxy to be externally managed to get access to all the Traefik's configuration options (including the ACME challenge type). + **Note 2** + + When using Let's Encrypt for certificate aquisition, Traefik **must** to be able to receive the HTTP-01 request from the ACME server on **port 80**. This is a Let's Encrypt limitation as described on the [Let's Encrypt community forum](https://community.letsencrypt.org/t/support-for-ports-other-than-80-and-443/3419/72) and the [Traefik docs](https://docs.traefik.io/v1.7/configuration/acme/#acme-challenge). + + The HTTP port used for the ACME HTTP-01 challenge is set by default to 80 in TraefikProxy. However, it can be configured for testing purposes (when using the ACME testing server, [Pebble](https://github.com/letsencrypt/pebble) for example) using: + + ```python + c.Proxy.traefik_acme_challenge_port=8000 + ``` + 2. **Manually**, by providing your own key and certificate: Providing a certificate and key can be done by configuring JupyterHub to enable SSL encryption as specified in [the docs](https://jupyterhub.readthedocs.io/en/stable/getting-started/security-basics.html?highlight=https#enabling-ssl-encryption). Example: From f07eabeaa588d54bd3c39d11327954891e6edb2d Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Wed, 3 Jun 2020 17:00:37 +0300 Subject: [PATCH 19/21] Add pebble env vars for more stability Co-authored-by: Erik Sundell --- .travis.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.travis.yml b/.travis.yml index ec319b8f..1c1c763c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,12 @@ env: - ETCDCTL_API=3 - LEGO_CA_SERVER_NAME=pebble - LEGO_CA_CERTIFICATES=$GOPATH/src/github.com/letsencrypt/pebble/test/certs/localhost/cert.pem + # ref: https://github.com/letsencrypt/pebble#testing-at-full-speed + - PEBBLE_VA_NOSLEEP=1 + # ref: https://github.com/letsencrypt/pebble#invalid-anti-replay-nonce-errors + - PEBBLE_WFE_NONCEREJECT=0 + # ref: https://github.com/letsencrypt/pebble#authorization-reuse + - PEBBLE_AUTHZREUSE=100 # installing dependencies before_install: From 7a1c39fec56dd4f661724a7b9eb11c44ee3ff3a8 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Wed, 3 Jun 2020 18:04:02 +0300 Subject: [PATCH 20/21] jupyter.test -> local.jovyan.org Co-authored-by: Erik Sundell --- .travis.yml | 3 --- tests/conftest.py | 6 +++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1c1c763c..ed3098b6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,6 @@ language: python cache: - pip -addons: - hosts: - - jupyter.test env: global: - ETCDCTL_API=3 diff --git a/tests/conftest.py b/tests/conftest.py index e6158af9..25eac4c9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,13 +18,13 @@ async def autohttps_toml_proxy(): """Fixture returning a configured Let's Encrypt TraefikTomlProxy""" proxy = TraefikTomlProxy( - public_url="https://jupyter.test:8443", + public_url="https://local.jovyan.org:8443", traefik_api_password="admin", traefik_api_username="api_admin", should_start=True, traefik_auto_https=True, - traefik_letsencrypt_email="jovyan@jupyter.test", - traefik_letsencrypt_domains=["jupyter.test"], + traefik_letsencrypt_email="jovyan@local.jovyan.org", + traefik_letsencrypt_domains=["local.jovyan.org"], traefik_acme_server="https://0.0.0.0:14000/dir", traefik_acme_challenge_port=8000 ) From e07e45eb40d61faec7529e393cb5cf4d5c8c2e61 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Wed, 3 Jun 2020 18:06:40 +0300 Subject: [PATCH 21/21] Give the acme server an actual addr Co-authored-by: Erik Sundell --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 25eac4c9..4352a5d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,7 +25,7 @@ async def autohttps_toml_proxy(): traefik_auto_https=True, traefik_letsencrypt_email="jovyan@local.jovyan.org", traefik_letsencrypt_domains=["local.jovyan.org"], - traefik_acme_server="https://0.0.0.0:14000/dir", + traefik_acme_server="https://localhost:14000/dir", traefik_acme_challenge_port=8000 )