diff --git a/.travis.yml b/.travis.yml index aeeb5548..ed3098b6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,12 +4,23 @@ cache: env: global: - 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: - 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 ./...) install: - python -m jupyterhub_traefik_proxy.install --traefik --etcd --consul --output=./bin - export PATH=$PWD/bin:$PATH 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/docs/source/https.md b/docs/source/https.md new file mode 100644 index 00000000..5c0bcd2b --- /dev/null +++ b/docs/source/https.md @@ -0,0 +1,52 @@ +# 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 + ``` + * Set the email address used for Let's Encrypt registration: + ```python + c.Proxy.traefik_letsencrypt_email="" + ``` + * 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 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: + ```python + c.JupyterHub.ssl_key = '/path/to/my.key' + c.JupyterHub.ssl_cert = '/path/to/my.cert' + ``` + +## 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/) \ No newline at end of file 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. --- diff --git a/jupyterhub_traefik_proxy/proxy.py b/jupyterhub_traefik_proxy/proxy.py index c8ba2886..4623f708 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,35 @@ 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_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""" + ) + + traefik_letsencrypt_email = Unicode(config=True, help="""Let's Encrypt email""") + + traefik_letsencrypt_domains = List(config=True, help="""domains list""") + + 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""" + ) + 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 +237,53 @@ async def _setup_traefik_static_config(self): self.static_config = {} self.static_config["debug"] = True self.static_config["logLevel"] = self.traefik_log_level + entryPoints = {} + scheme = urlparse(self.public_url).scheme + address = urlparse(self.public_url).netloc + port = urlparse(self.public_url).port - if self.ssl_cert and self.ssl_key: - self.static_config["defaultentrypoints"] = ["https"] - entryPoints["https"] = { - "address": ":" + str(urlparse(self.public_url).port), - "tls": { - "certificates": [ - {"certFile": self.ssl_cert, "keyFile": self.ssl_key} - ] - }, + 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[scheme] = {"address": address} + + if self.traefik_auto_https: + entryPoints["https"].update({"tls": {}}) + entryPoints["acme_challenge"] = { + "address": ":" + str(self.traefik_acme_challenge_port) } - else: - self.static_config["defaultentrypoints"] = ["http"] - entryPoints["http"] = {"address": ":" + str(urlparse(self.public_url).port)} + + acme = { + "email": self.traefik_letsencrypt_email, + "storage": self.traefik_acme_storage, + "entryPoint": "https", + "caServer": self.traefik_acme_server, + "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: + entryPoints["https"].update( + { + "tls": { + "certificates": [ + {"certFile": self.ssl_cert, "keyFile": self.ssl_key} + ] + } + } + ) auth = { "basic": { @@ -248,20 +300,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/tests/config_files/pebble-config.json b/tests/config_files/pebble-config.json new file mode 100644 index 00000000..be4d3ff6 --- /dev/null +++ b/tests/config_files/pebble-config.json @@ -0,0 +1,10 @@ +{ + "pebble": { + "listenAddress": "0.0.0.0:14000", + "managementListenAddress": "0.0.0.0:15000", + "httpPort": 8000, + "tlsPort": 8443, + "ocspResponderURL": "", + "externalAccountBindingRequired": false + } +} \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 6f9096ac..4352a5d7 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 @@ -13,6 +14,28 @@ 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="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@local.jovyan.org", + traefik_letsencrypt_domains=["local.jovyan.org"], + traefik_acme_server="https://localhost:14000/dir", + traefik_acme_challenge_port=8000 + ) + + await proxy.start() + yield proxy + await proxy.stop() + if os.path.exists("acme.json"): + os.remove("acme.json") + + @pytest.fixture async def no_auth_consul_proxy(consul_no_acl): """ @@ -190,6 +213,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 f5c6a51b..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: @@ -356,3 +359,42 @@ async def test_websockets(proxy, launch_backend): port = await websocket.recv() assert port == str(default_backend_port) + + +async def test_autohttps(pebble, autohttps_toml_proxy, 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, {}) + + 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) + assert backend_port == int(resp.body.decode("utf-8")) diff --git a/tests/utils.py b/tests/utils.py index b4934a5c..c4161c6a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -32,6 +32,20 @@ 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", timeout=30 + ) + + async def get_responding_backend_port(traefik_url, path): """ Check if traefik followed the configuration and routed the request to the right backend """