Merge pull request #861 from minrk/traefik-v2

Traefik v2, TraefikProxy v1
This commit is contained in:
Erik Sundell
2023-05-18 14:25:03 +02:00
committed by GitHub
12 changed files with 378 additions and 202 deletions

View File

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

View File

@@ -40,6 +40,7 @@ default = {
"letsencrypt": {
"email": "",
"domains": [],
"staging": False,
},
},
"traefik_api": {
@@ -239,8 +240,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):

View File

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

View File

@@ -0,0 +1,32 @@
# 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"
cipherSuites = [
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305",
"TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305",
]
{%- 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 %}

View File

@@ -1,40 +1,53 @@
"""Traefik installation and setup"""
import hashlib
import io
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
from .config import CONFIG_DIR
# traefik 2.7.x is not supported yet, use v1.7.x for now
# see: https://github.com/jupyterhub/traefik-proxy/issues/97
logger = logging.getLogger("tljh")
machine = os.uname().machine
if machine == "aarch64":
plat = "linux-arm64"
plat = "linux_arm64"
elif machine == "x86_64":
plat = "linux-amd64"
plat = "linux_amd64"
else:
raise OSError(f"Error. Platform: {os.uname().sysname} / {machine} Not supported.")
traefik_version = "1.7.33"
plat = None
# Traefik releases: https://github.com/traefik/traefik/releases
traefik_version = "2.10.1"
# record sha256 hashes for supported platforms here
# checksums are published in the checksums.txt of each release
checksums = {
"linux-amd64": "314ffeaa4cd8ed6ab7b779e9b6773987819f79b23c28d7ab60ace4d3683c5935",
"linux-arm64": "0640fa665125efa6b598fc08c100178e24de66c5c6035ce5d75668d3dc3706e1",
"linux_amd64": "8d9bce0e6a5bf40b5399dbb1d5e3e5c57b9f9f04dd56a2dd57cb0713130bc824",
"linux_arm64": "260a574105e44901f8c9c562055936d81fbd9c96a21daaa575502dc69bfe390a",
}
_tljh_path = Path(__file__).parent.resolve()
def checksum_file(path):
def checksum_file(path_or_file):
"""Compute the sha256 checksum of a path"""
hasher = hashlib.sha256()
with open(path, "rb") as f:
if hasattr(path_or_file, "read"):
f = path_or_file
else:
f = open(path_or_file, "rb")
with f:
for chunk in iter(lambda: f.read(4096), b""):
hasher.update(chunk)
return hasher.hexdigest()
@@ -45,48 +58,71 @@ def fatal_error(e):
return str(e) != "ContentTooShort" and not isinstance(e, ConnectionResetError)
def check_traefik_version(traefik_bin):
"""Check the traefik version from `traefik version` output"""
try:
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
for line in version_out.splitlines():
before, _, after = line.partition(":")
key = before.strip()
if key.lower() == "version":
version = after.strip()
if version == traefik_version:
logger.debug(f"Found {traefik_bin} {version}")
return True
else:
logger.info(
f"Found {traefik_bin} version {version} != {traefik_version}"
)
return False
logger.debug(f"Failed to extract traefik version from: {version_out}")
return False
@backoff.on_exception(backoff.expo, Exception, max_tries=2, giveup=fatal_error)
def ensure_traefik_binary(prefix):
"""Download and install the traefik binary to a location identified by a prefix path such as '/opt/tljh/hub/'"""
if plat is None:
raise OSError(
f"Error. Platform: {os.uname().sysname} / {machine} Not supported."
)
traefik_bin_dir = os.path.join(prefix, "bin")
traefik_bin = os.path.join(prefix, "bin", "traefik")
if os.path.exists(traefik_bin):
checksum = checksum_file(traefik_bin)
if checksum == checksums[plat]:
# already have the right binary
# ensure permissions and we're done
os.chmod(traefik_bin, 0o755)
if check_traefik_version(traefik_bin):
return
else:
print(f"checksum mismatch on {traefik_bin}")
os.remove(traefik_bin)
traefik_url = (
"https://github.com/containous/traefik/releases"
f"/download/v{traefik_version}/traefik_{plat}"
"https://github.com/traefik/traefik/releases"
f"/download/v{traefik_version}/traefik_v{traefik_version}_{plat}.tar.gz"
)
print(f"Downloading traefik {traefik_version}...")
logger.info(f"Downloading traefik {traefik_version} from {traefik_url}...")
# download the file
response = requests.get(traefik_url)
response.raise_for_status()
if response.status_code == 206:
raise Exception("ContentTooShort")
with open(traefik_bin, "wb") as f:
f.write(response.content)
os.chmod(traefik_bin, 0o755)
# verify that we got what we expected
checksum = checksum_file(traefik_bin)
checksum = checksum_file(io.BytesIO(response.content))
if checksum != checksums[plat]:
raise OSError(f"Checksum failed {traefik_bin}: {checksum} != {checksums[plat]}")
raise OSError(f"Checksum failed {traefik_url}: {checksum} != {checksums[plat]}")
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
with tarfile.open(fileobj=io.BytesIO(response.content)) as tf:
tf.extract("traefik", path=traefik_bin_dir)
os.chmod(traefik_bin, 0o755)
def load_extra_config(extra_config_dir):
@@ -101,16 +137,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"]
@@ -125,6 +158,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)
@@ -143,6 +184,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)

View File

@@ -1,74 +1,63 @@
# 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"
{% if https['enabled'] %}
[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 %}
{% 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'] %}
[certificatesResolvers.letsencrypt.acme]
email = "{{ https['letsencrypt']['email'] }}"
storage = "acme.json"
entryPoint = "https"
[acme.httpChallenge]
entryPoint = "http"
{%- if https['letsencrypt']['staging'] %}
caServer = "https://acme-staging-v02.api.letsencrypt.org/directory"
{%- endif %}
[certificatesResolvers.letsencrypt.acme.tlsChallenge]
{%- endif %}
{% for domain in https['letsencrypt']['domains'] %}
[[acme.domains]]
main = "{{domain}}"
{% endfor %}
{% endif %}
[providers]
providersThrottleDuration = "0s"
[file]
directory = "rules"
[providers.file]
directory = "{{ traefik_dynamic_config_dir }}"
watch = true