mirror of
https://github.com/jupyterhub/the-littlest-jupyterhub.git
synced 2025-12-18 21:54:05 +08:00
Merge pull request #861 from minrk/traefik-v2
Traefik v2, TraefikProxy v1
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
32
tljh/traefik-dynamic.toml.tpl
Normal file
32
tljh/traefik-dynamic.toml.tpl
Normal 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 %}
|
||||
129
tljh/traefik.py
129
tljh/traefik.py
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user