"""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 tljh.configurer import _merge_dictionaries, load_config from .config import CONFIG_DIR logger = logging.getLogger("tljh") machine = os.uname().machine if machine == "aarch64": plat = "linux_arm64" elif machine == "x86_64": plat = "linux_amd64" else: 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": "8d9bce0e6a5bf40b5399dbb1d5e3e5c57b9f9f04dd56a2dd57cb0713130bc824", "linux_arm64": "260a574105e44901f8c9c562055936d81fbd9c96a21daaa575502dc69bfe390a", } _tljh_path = Path(__file__).parent.resolve() def checksum_file(path_or_file): """Compute the sha256 checksum of a path""" hasher = hashlib.sha256() 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() def fatal_error(e): # Retry only when connection is reset or we think we didn't download entire file 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): if check_traefik_version(traefik_bin): return else: os.remove(traefik_bin) traefik_url = ( "https://github.com/traefik/traefik/releases" f"/download/v{traefik_version}/traefik_v{traefik_version}_{plat}.tar.gz" ) 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") # verify that we got what we expected checksum = checksum_file(io.BytesIO(response.content)) if checksum != checksums[plat]: raise OSError(f"Checksum failed {traefik_url}: {checksum} != {checksums[plat]}") 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): extra_configs = sorted(glob(os.path.join(extra_config_dir, "*.toml"))) # Load the toml list of files into dicts and merge them config = toml.load(extra_configs) return config def ensure_traefik_config(state_dir): """Render the traefik.toml config file""" 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") traefik_dynamic_config_file = os.path.join( traefik_dynamic_config_dir, "dynamic.toml" ) config = load_config() config["traefik_dynamic_config_dir"] = traefik_dynamic_config_dir https = config["https"] letsencrypt = https["letsencrypt"] tls = https["tls"] # validate https config if https["enabled"]: if not tls["cert"] and not letsencrypt["email"]: raise ValueError( "To enable https, you must set tls.cert+key or letsencrypt.email+domains" ) if (letsencrypt["email"] and not letsencrypt["domains"]) or ( letsencrypt["domains"] and not letsencrypt["email"] ): 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) # Ensure traefik dynamic config dir exists and is private os.makedirs(traefik_dynamic_config_dir, mode=0o700, exist_ok=True) try: # Load standard config file merge it with the extra config files into a dict extra_config = load_extra_config(traefik_extra_config_dir) new_toml = _merge_dictionaries(toml.loads(std_config), extra_config) except FileNotFoundError: new_toml = toml.loads(std_config) # Dump the dict into a toml-formatted string and write it to file with open(traefik_std_config_file, "w") as f: 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) # ensure acme.json exists and is private with open(os.path.join(state_dir, "acme.json"), "a") as f: os.fchmod(f.fileno(), 0o600)