"""Create/renew LAN TLS certificates for Traefik. Run on Linux with OpenSSL. An existing CA can be installed as tls/ca/root.crt and tls/ca/root.key before the first run. The CA key is never mounted into the proxy or copied into images. """ import argparse import hashlib import ipaddress import json import os from pathlib import Path import re import secrets import subprocess import tempfile def openssl(*args, check=True): return subprocess.run(["openssl", *map(str, args)], check=check, capture_output=True) def identity(value): try: host = str(ipaddress.ip_address(value)) return host, "IP", "-verify_ip" except ValueError: host = value.encode("idna").decode("ascii").lower() if len(host) > 253 or any(not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) for label in host.split(".")): raise ValueError("Invalid hostname. Supply only a DNS name or IP address, without a port.") return host, "DNS", "-verify_hostname" def publish(path, content): # Publish complete config/state files atomically. Traefik watches the directory. with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, prefix=".tls-", suffix=".tmp", delete=False) as stream: temporary = Path(stream.name) stream.write(content) try: temporary.replace(path) finally: temporary.unlink(missing_ok=True) def renew(project, host, *, force=False): import fcntl host, san_type, verify_flag = identity(host) os.umask(0o077) ca = project / "tls" / "ca" certificates = project / "tls" / "certs" dynamic = project / "traefik" / "dynamic" for directory in (ca, certificates, dynamic): directory.mkdir(parents=True, exist_ok=True) with (project / "tls" / "renew.lock").open("a") as lock: fcntl.flock(lock, fcntl.LOCK_EX) root_cert, root_key = ca / "root.crt", ca / "root.key" if root_cert.exists() != root_key.exists(): raise RuntimeError("Incomplete CA: both root.crt and root.key are required. Existing files were retained.") if not root_cert.exists(): openssl("genpkey", "-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256", "-out", root_key) openssl("req", "-new", "-x509", "-sha256", "-key", root_key, "-out", root_cert, "-days", "3650", "-subj", "/CN=CS-R Local Authority", "-addext", "basicConstraints=critical,CA:TRUE", "-addext", "keyUsage=critical,keyCertSign,cRLSign") root_key.chmod(0o600) # Refuse to issue a certificate beyond the lifetime of its trust anchor. openssl("x509", "-in", root_cert, "-checkend", str(91 * 86400), "-noout") fingerprint = hashlib.sha256(root_cert.read_bytes()).hexdigest() state_path = certificates / "current.json" state = json.loads(state_path.read_text()) if state_path.exists() else {} serial = state.get("serial", "") config = dynamic / "tls.yml" if not force and state.get("host") == host and state.get("ca") == fingerprint and re.fullmatch(r"[0-9a-f]{32}", serial): cert, key = certificates / f"{serial}.crt", certificates / f"{serial}.key" if cert.exists() and key.exists() and config.exists(): valid = openssl("verify", "-CAfile", root_cert, "-purpose", "sslserver", verify_flag, host, cert, check=False) lifetime = openssl("x509", "-in", cert, "-checkend", str(30 * 86400), "-noout", check=False) if valid.returncode == lifetime.returncode == 0: print("TLS certificate is valid for more than 30 days; no renewal needed.") return serial = secrets.token_hex(16) cert, key = certificates / f"{serial}.crt", certificates / f"{serial}.key" with tempfile.TemporaryDirectory(dir=certificates, prefix=".issue-") as temporary: temp = Path(temporary) extensions = temp / "extensions.cnf" extensions.write_text( "basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\n" f"extendedKeyUsage=serverAuth\nsubjectAltName={san_type}:{host}\n" ) openssl("genpkey", "-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256", "-out", key) openssl("req", "-new", "-key", key, "-out", temp / "request.csr", "-subj", "/CN=CS-R Inventory") openssl("x509", "-req", "-in", temp / "request.csr", "-CA", root_cert, "-CAkey", root_key, "-set_serial", "0x" + serial, "-days", "90", "-sha256", "-extfile", extensions, "-out", cert) openssl("verify", "-CAfile", root_cert, "-purpose", "sslserver", verify_flag, host, cert) publish(config, f"""tls: certificates: - certFile: /certs/{serial}.crt keyFile: /certs/{serial}.key stores: default: defaultCertificate: certFile: /certs/{serial}.crt keyFile: /certs/{serial}.key options: default: minVersion: VersionTLS12 """) publish(state_path, json.dumps({"host": host, "ca": fingerprint, "serial": serial})) print(f"TLS certificate issued for {host}; Traefik will reload it automatically.") if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--hostname", required=True) parser.add_argument("--project", type=Path, default=Path(__file__).resolve().parent.parent) parser.add_argument("--force", action="store_true") arguments = parser.parse_args() renew(arguments.project.resolve(), arguments.hostname, force=arguments.force)