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 """