Skip to content

Commit 2289b96

Browse files
committed
wip: simplify ssl configuration
- move SSL configuration to entrypoint instead of on every router - add passthrough extra_static_config and extra_dynamic_config options for traefik - remove several individual config keys for providers in favor of generalized passthrough `etcd_client_kwargs` and `consul_client_kwargs`
1 parent 8570181 commit 2289b96

File tree

11 files changed

+331
-185
lines changed

11 files changed

+331
-185
lines changed

dev-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
black
2+
certipy
23
codecov
34
# etcd3 & python-consul2 are now soft dependencies
45
# Adding them here prevents CI from failing

jupyterhub_traefik_proxy/consul.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@
2222
import string
2323
from urllib.parse import urlparse
2424

25-
from traitlets import Any, Unicode, default
25+
from traitlets import Any, Dict, Unicode, default
2626

2727
from .kv_proxy import TKvProxy
28+
from .traefik_utils import deep_merge
2829

2930

3031
class TraefikConsulProxy(TKvProxy):
@@ -35,11 +36,9 @@ class TraefikConsulProxy(TKvProxy):
3536
# Consul doesn't accept keys containing // or starting with / so we have to escape them
3637
key_safe_chars = string.ascii_letters + string.digits + "!@#$%^&*();<>-.+?:"
3738

38-
consul_client_ca_cert = Unicode(
39+
consul_client_kwargs = Dict(
3940
config=True,
40-
allow_none=True,
41-
default_value=None,
42-
help="""Consul client root certificates""",
41+
help="Extra consul client constructor arguments",
4342
)
4443

4544
consul_url = Unicode(
@@ -85,10 +84,11 @@ def _default_client(self):
8584
kwargs = {
8685
"host": consul_service.hostname,
8786
"port": consul_service.port,
88-
"cert": self.consul_client_ca_cert,
8987
}
9088
if self.consul_password:
9189
kwargs.update({"token": self.consul_password})
90+
if self.consul_client_kwargs:
91+
kwargs.update(self.consul_client_kwargs)
9292
return consul.aio.Consul(**kwargs)
9393

9494
def __init__(self, **kwargs):
@@ -105,11 +105,9 @@ def _setup_traefik_static_config(self):
105105
}
106106
}
107107

108-
# FIXME: Same with the tls info
109-
if self.consul_client_ca_cert:
110-
provider_config["consul"]["tls"] = {"ca": self.consul_client_ca_cert}
111-
112-
self.static_config.update({"providers": provider_config})
108+
self.static_config = deep_merge(
109+
self.static_config, {"providers": provider_config}
110+
)
113111
return super()._setup_traefik_static_config()
114112

115113
def _start_traefik(self):

jupyterhub_traefik_proxy/etcd.py

Lines changed: 21 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@
2222
from urllib.parse import urlparse
2323

2424
from tornado.concurrent import run_on_executor
25-
from traitlets import Any, Bool, List, Unicode, default
25+
from traitlets import Any, Dict, Unicode, default
2626

2727
from .kv_proxy import TKvProxy
28+
from .traefik_utils import deep_merge
2829

2930

3031
class TraefikEtcdProxy(TKvProxy):
@@ -34,42 +35,9 @@ class TraefikEtcdProxy(TKvProxy):
3435

3536
provider_name = "etcd"
3637

37-
etcd_client_ca_cert = Unicode(
38+
etcd_client_kwargs = Dict(
3839
config=True,
39-
allow_none=True,
40-
default_value=None,
41-
help="""Etcd client root certificates""",
42-
)
43-
44-
etcd_client_cert_crt = Unicode(
45-
config=True,
46-
allow_none=True,
47-
default_value=None,
48-
help="""Etcd client certificate chain
49-
(etcd_client_cert_key must also be specified)""",
50-
)
51-
52-
etcd_client_cert_key = Unicode(
53-
config=True,
54-
allow_none=True,
55-
default_value=None,
56-
help="""Etcd client private key
57-
(etcd_client_cert_crt must also be specified)""",
58-
)
59-
60-
# The grpc client (used by the Python etcd library) doesn't allow untrusted
61-
# etcd certificates, although traefik does allow them.
62-
etcd_insecure_skip_verify = Bool(
63-
False,
64-
config=True,
65-
help="""Traefik will by default validate SSL certificate of etcd backend""",
66-
)
67-
68-
grpc_options = List(
69-
config=True,
70-
allow_none=True,
71-
default_value=None,
72-
help="""Any grpc options that need to be passed to the etcd client""",
40+
help="""Extra keyword arguments to pass to the etcd Python client constructor""",
7341
)
7442

7543
@default("executor")
@@ -120,10 +88,6 @@ def _default_client(self):
12088
kwargs = {
12189
'host': etcd_service.hostname,
12290
'port': etcd_service.port,
123-
'ca_cert': self.etcd_client_ca_cert,
124-
'cert_cert': self.etcd_client_cert_crt,
125-
'cert_key': self.etcd_client_cert_key,
126-
'grpc_options': self.grpc_options,
12791
}
12892
if self.etcd_password:
12993
kwargs.update(
@@ -132,6 +96,9 @@ def _default_client(self):
13296
"password": self.etcd_password,
13397
}
13498
)
99+
if self.etcd_client_kwargs:
100+
kwargs.update(self.etcd_client_kwargs)
101+
print(f"Creating etcd with {kwargs=}")
135102
return etcd3.client(**kwargs)
136103

137104
def _cleanup(self):
@@ -194,29 +161,27 @@ async def _kv_atomic_delete(self, *keys):
194161
def _setup_traefik_static_config(self):
195162
self.log.debug("Setting up the etcd provider in the static config")
196163
url = urlparse(self.etcd_url)
197-
self.static_config.update(
198-
{
199-
"providers": {
200-
"etcd": {
201-
"endpoints": [url.netloc],
202-
"rootKey": self.kv_traefik_prefix,
203-
}
204-
}
205-
}
206-
)
164+
etcd_config = {
165+
"endpoints": [url.netloc],
166+
"rootKey": self.kv_traefik_prefix,
167+
}
207168
if url.scheme == "https":
208169
# If etcd is running over TLS, then traefik needs to know
209-
tls_conf = {}
210-
if self.etcd_client_ca_cert is not None:
211-
tls_conf["ca"] = self.etcd_client_ca_cert
212-
tls_conf["insecureSkipVerify"] = self.etcd_insecure_skip_verify
213-
self.static_config["providers"]["etcd"]["tls"] = tls_conf
170+
etcd_config["tls"] = {}
171+
# tls_conf = {}
172+
# if self.etcd_client_ca_cert is not None:
173+
# tls_conf["ca"] = self.etcd_client_ca_cert
174+
# tls_conf["insecureSkipVerify"] = self.etcd_insecure_skip_verify
175+
# self.static_config["providers"]["etcd"]["tls"] = tls_conf
214176

215177
if self.etcd_username and self.etcd_password:
216-
self.static_config["providers"]["etcd"].update(
178+
etcd_config.update(
217179
{
218180
"username": self.etcd_username,
219181
"password": self.etcd_password,
220182
}
221183
)
184+
self.static_config = deep_merge(
185+
self.static_config, {"providers": {"etcd": etcd_config}}
186+
)
222187
return super()._setup_traefik_static_config()

jupyterhub_traefik_proxy/proxy.py

Lines changed: 69 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from traitlets import Any, Bool, Dict, Integer, Unicode, default, observe, validate
3333

3434
from . import traefik_utils
35+
from .traefik_utils import deep_merge
3536

3637

3738
class TraefikProxy(Proxy):
@@ -65,6 +66,25 @@ def _concurrency_changed(self, change):
6566
"traefik.toml", config=True, help="""traefik's static configuration file"""
6667
)
6768

69+
extra_static_config = Dict(
70+
config=True,
71+
help="""Extra static configuration for treafik.
72+
73+
Merged with the default static config before writing to `.static_config_file`.
74+
75+
Has no effect if `Proxy.should_start` is False.
76+
""",
77+
)
78+
extra_dynamic_config = Dict(
79+
config=True,
80+
help="""Extra dynamic configuration for treafik.
81+
82+
Merged with the default dynamic config during startup.
83+
84+
Always takes effect.
85+
""",
86+
)
87+
6888
toml_static_config_file = Unicode(
6989
config=True,
7090
help="Deprecated. Use static_config_file",
@@ -188,13 +208,6 @@ def _add_port(self, proposal):
188208
url = urlunparse(parsed)
189209
return url
190210

191-
traefik_cert_resolver = Unicode(
192-
config=True,
193-
help="""The traefik certificate Resolver to use for requesting certificates""",
194-
)
195-
196-
# FIXME: How best to enable TLS on routers assigned to only select
197-
# entrypoints defined here?
198211
traefik_entrypoint = Unicode(
199212
help="""The traefik entrypoint name to use.
200213
@@ -414,14 +427,16 @@ async def _setup_traefik_static_config(self):
414427
Subclasses should specify any traefik providers themselves, in
415428
:attrib:`self.static_config["providers"]`
416429
"""
417-
self.static_config["providers"][
418-
"providersThrottleDuration"
419-
] = self.traefik_providers_throttle_duration
420-
430+
static_config = {
431+
"api": {},
432+
"providers": {
433+
"providersThrottleDuration": self.traefik_providers_throttle_duration,
434+
},
435+
}
421436
if self.traefik_log_level:
422-
self.static_config["log"] = {"level": self.traefik_log_level}
437+
static_config["log"] = {"level": self.traefik_log_level}
423438

424-
entrypoints = {
439+
entrypoints = static_config["entryPoints"] = {
425440
self.traefik_entrypoint: {
426441
"address": urlparse(self.public_url).netloc,
427442
},
@@ -430,8 +445,20 @@ async def _setup_traefik_static_config(self):
430445
},
431446
}
432447

433-
self.static_config["entryPoints"] = entrypoints
434-
self.static_config["api"] = {}
448+
if self.is_https:
449+
entrypoints[self.traefik_entrypoint]["http"] = {
450+
"tls": {
451+
"options": "default",
452+
}
453+
}
454+
455+
# load what we just defined at _lower_ priority
456+
# than anything added to self.static_config in a subclass before this
457+
self.static_config = deep_merge(static_config, self.static_config)
458+
if self.extra_static_config:
459+
self.static_config = deep_merge(
460+
self.static_config, self.extra_static_config
461+
)
435462

436463
self.log.info(f"Writing traefik static config: {self.static_config}")
437464

@@ -446,29 +473,46 @@ async def _setup_traefik_dynamic_config(self):
446473
self.log.debug("Setting up traefik's dynamic config...")
447474
self._generate_htpassword()
448475
api_url = urlparse(self.traefik_api_url)
449-
api_path = api_url.path if api_url.path else '/api'
476+
api_path = api_url.path if api_url.path.strip("/") else '/api'
450477
api_credentials = (
451478
f"{self.traefik_api_username}:{self.traefik_api_hashed_password}"
452479
)
453-
http = self.dynamic_config.setdefault("http", {})
454-
routers = http.setdefault("routers", {})
480+
dynamic_config = {
481+
"http": {
482+
"routers": {},
483+
"middlewares": {},
484+
}
485+
}
486+
dynamic_config["http"]
487+
routers = dynamic_config["http"]["routers"]
488+
middlewares = dynamic_config["http"]["middlewares"]
455489
routers["route_api"] = {
456490
"rule": f"Host(`{api_url.hostname}`) && PathPrefix(`{api_path}`)",
457491
"entryPoints": [self.traefik_api_entrypoint],
458492
"service": "api@internal",
459493
"middlewares": ["auth_api"],
460494
}
461-
middlewares = http.setdefault("middlewares", {})
462495
middlewares["auth_api"] = {"basicAuth": {"users": [api_credentials]}}
496+
497+
# add default ssl cert/keys
463498
if self.ssl_cert and self.ssl_key:
464-
tls = self.dynamic_config.setdefault("tls", {})
465-
stores = tls.setdefault("stores", {})
466-
stores["default"] = {
467-
"defaultCertificate": {
468-
"certFile": self.ssl_cert,
469-
"keyFile": self.ssl_key,
499+
dynamic_config["tls"] = {
500+
"stores": {
501+
"default": {
502+
"defaultCertificate": {
503+
"certFile": self.ssl_cert,
504+
"keyFile": self.ssl_key,
505+
}
506+
}
470507
}
471508
}
509+
510+
self.dynamic_config = deep_merge(dynamic_config, self.dynamic_config)
511+
if self.extra_dynamic_config:
512+
self.dynamic_config = deep_merge(
513+
self.dynamic_config, self.extra_dynamic_config
514+
)
515+
472516
await self._apply_dynamic_config(self.dynamic_config, None)
473517

474518
def validate_routespec(self, routespec):
@@ -556,19 +600,6 @@ def _dynamic_config_for_route(self, routespec, target, data):
556600
"loadBalancer": {"servers": [{"url": target}], "passHostHeader": True}
557601
}
558602

559-
# Enable TLS on this router if globally enabled
560-
if self.is_https:
561-
tls_config = {}
562-
if self.traefik_cert_resolver:
563-
tls_config["certResolver"] = self.traefik_cert_resolver
564-
else:
565-
# we need _some_ key to be set
566-
# because key-value stores can't store empty dicts.
567-
# put a default value here
568-
tls_config["options"] = "default"
569-
570-
router["tls"] = tls_config
571-
572603
# Add the data node to a separate top-level node, so traefik doesn't see it.
573604
# key needs to be key-value safe (no '/')
574605
# store original routespec, router, service aliases for easy lookup
File renamed without changes.
File renamed without changes.
File renamed without changes.

tests/config_files/traefik.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,8 @@ watch = true
1313
[entryPoints.https]
1414
address = "127.0.0.1:8000"
1515

16+
[entryPoints.https.http.tls]
17+
options = "default"
18+
1619
[entryPoints.auth_api]
1720
address = "127.0.0.1:8099"

0 commit comments

Comments
 (0)