#!/usr/bin/env python3 """Private Certbot HTTP-01 / DNS-01 multi-provider adapter. JSON input on stdin, no shell. This file is also embedded in the Rust binary. Never pass a TSIG secret on the command line. No network operation happens at import or in --doctor. """ from __future__ import annotations import argparse import base64 import datetime import hashlib import ipaddress import json import os from pathlib import Path import re import shutil import stat import subprocess import sys import uuid DIRECTORIES = { "staging": "https://acme-staging-v02.api.letsencrypt.org/directory", "production": "https://acme-v02.api.letsencrypt.org/directory", } ALGORITHMS = {"HMAC-SHA256", "HMAC-SHA384", "HMAC-SHA512"} MAX_INPUT = 16384 MAX_PEM = 131072 PROVIDERS = {"http01", "cloudflare", "ovh", "rfc2136"} SECRET_FIELDS = {"key_secret", "api_token", "application_key", "application_secret", "consumer_key", "credential_ini_b64"} COMMON_FIELDS = {"provider", "certificate_id", "email", "domains", "environment", "agree_tos", "confirm_production", "propagation_seconds"} PROVIDER_FIELDS = { "http01": set(), "cloudflare": {"api_token"}, "ovh": {"endpoint", "application_key", "application_secret", "consumer_key"}, "rfc2136": {"dns_server", "dns_port", "key_name", "key_secret", "key_algorithm", "sign_query"}, } def credential(value: object) -> str: # API keys are ASCII tokens; reject anything that could inject INI content. if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_./+\-=]{8,4096}", value): raise AcmeError("acme.invalid_api_credential") return value class AcmeError(Exception): """Messages contain fixed diagnostics, never provider output or input secrets.""" def domain(value: object, wildcard: bool = True, single_label: bool = False) -> str: if not isinstance(value, str) or not value.isascii() or len(value) > 253: raise AcmeError("acme.invalid_domain") value = value.lower().rstrip(".") check = value[2:] if wildcard and value.startswith("*.") else value if (not single_label and "." not in check) or any(not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", p) for p in check.split(".")): raise AcmeError("acme.invalid_domain") try: ipaddress.ip_address(check) except ValueError: return value raise AcmeError("acme.dns_names_only") def validate(raw: object) -> dict: if not isinstance(raw, dict): raise AcmeError("acme.invalid_request") provider = raw.get("provider", "rfc2136") if not isinstance(provider, str) or provider not in PROVIDERS: raise AcmeError("acme.invalid_provider") if set(raw) - (COMMON_FIELDS | PROVIDER_FIELDS[provider]): raise AcmeError("acme.unknown_field") value = dict(raw, provider=provider) if not isinstance(value.get("certificate_id"), str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", value["certificate_id"]): raise AcmeError("acme.invalid_id") email = value.get("email", "") if not isinstance(email, str) or not email.isascii() or len(email) > 254 or not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email): raise AcmeError("acme.invalid_email") names = value.get("domains") if not isinstance(names, list) or not 1 <= len(names) <= 16: raise AcmeError("acme.invalid_domains") if provider == "http01" and any(isinstance(d, str) and d.startswith("*.") for d in names): raise AcmeError("acme.http01_no_wildcard") value["domains"] = list(dict.fromkeys(domain(d) for d in names)) value.setdefault("environment", "staging") if not isinstance(value["environment"], str) or value["environment"] not in DIRECTORIES: raise AcmeError("acme.invalid_environment") if value.get("agree_tos") is not True: raise AcmeError("acme.tos_required") if value["environment"] == "production" and value.get("confirm_production") is not True: raise AcmeError("acme.production_confirmation") propagation = value.get("propagation_seconds", 60) if type(propagation) is not int or not 10 <= propagation <= 900: raise AcmeError("acme.invalid_limits") value["propagation_seconds"] = propagation if provider == "http01": return value if provider == "cloudflare": value["api_token"] = credential(value.get("api_token")) return value if provider == "ovh": if value.get("endpoint", "ovh-eu") not in ("ovh-eu", "ovh-ca"): raise AcmeError("acme.invalid_ovh_endpoint") value.setdefault("endpoint", "ovh-eu") for name in ("application_key", "application_secret", "consumer_key"): value[name] = credential(value.get(name)) return value if not isinstance(value.get("dns_server"), str): raise AcmeError("acme.invalid_dns_server") try: value["dns_server"] = str(ipaddress.ip_address(value.get("dns_server", ""))) except ValueError: raise AcmeError("acme.invalid_dns_server") from None for name, default, low, high in [("dns_port", 53, 1, 65535), ("propagation_seconds", 60, 10, 900)]: v = value.get(name, default) if type(v) is not int or not low <= v <= high: raise AcmeError("acme.invalid_limits") value[name] = v value["key_name"] = domain(value.get("key_name", ""), wildcard=False, single_label=True) + "." value.setdefault("key_algorithm", "HMAC-SHA256") if not isinstance(value["key_algorithm"], str) or value["key_algorithm"] not in ALGORITHMS: raise AcmeError("acme.invalid_key_algorithm") secret = value.get("key_secret", "") try: if not isinstance(secret, str) or len(secret) > 1024 or not 16 <= len(base64.b64decode(secret, validate=True)) <= 512: raise ValueError() except (ValueError, TypeError): raise AcmeError("acme.invalid_key_secret") from None value.setdefault("sign_query", False) if type(value["sign_query"]) is not bool: raise AcmeError("acme.invalid_sign_query") return value def private_directory(path: Path) -> None: path.mkdir(mode=0o700, parents=True, exist_ok=True) info = path.lstat() if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077: raise AcmeError("acme.unsafe_directory") def write_new(path: Path, data: bytes) -> None: fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600) with os.fdopen(fd, "wb") as stream: stream.write(data) stream.flush() os.fsync(stream.fileno()) def credentials(value: dict) -> bytes: if value["provider"] == "http01": return b"" if value["provider"] == "cloudflare": return f"dns_cloudflare_api_token = {value['api_token']}\n".encode("ascii") if value["provider"] == "ovh": return "".join(f"dns_ovh_{k} = {value[k]}\n" for k in ("endpoint", "application_key", "application_secret", "consumer_key")).encode("ascii") lines = { "server": value["dns_server"], "port": value["dns_port"], "name": value["key_name"], "secret": value["key_secret"], "algorithm": value["key_algorithm"], "sign_query": str(value["sign_query"]).lower(), } return "".join(f"dns_rfc2136_{k} = {v}\n" for k, v in lines.items()).encode("ascii") def certbot_prefix(executable: str | None = None) -> list[str]: if executable: return [executable] managed_certbot = os.environ.get("RUSTPROXY_CERTBOT_EXECUTABLE") if managed_certbot: return [managed_certbot] discovered = shutil.which("certbot") if discovered: return [discovered] raise AcmeError("acme.certbot_not_installed") def certbot_command(value: dict, work: Path, credential_file: Path, command_prefix: list[str]) -> list[str]: args = [*command_prefix, "certonly", "--non-interactive", "--agree-tos", "--no-eff-email", "--email", value["email"], "--server", DIRECTORIES[value["environment"]], "--cert-name", "rustproxy", "--keep-until-expiring", "--key-type", "ecdsa", "--config-dir", str(work / "config"), "--work-dir", str(work / "work"), "--logs-dir", str(work / "logs")] if value["provider"] == "http01": args += ["--authenticator", "webroot", "--webroot-path", str(work.parent.parent / "acme-http01"), "--preferred-challenges", "http-01"] else: plugin = "dns-" + value["provider"] args += ["--authenticator", plugin, "--" + plugin + "-credentials", str(credential_file), "--" + plugin + "-propagation-seconds", str(value["propagation_seconds"])] for name in value["domains"]: args += ["-d", name] return args def read_generated(work: Path, name: str) -> bytes: # Resolve live symlinks only inside Certbot's archive. archive = (work / "config" / "archive").resolve(strict=True) path = (work / "config" / "live" / "rustproxy" / name).resolve(strict=True) if not path.is_relative_to(archive): raise AcmeError("acme.unsafe_output_path") fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) with os.fdopen(fd, "rb") as stream: info = os.fstat(stream.fileno()) if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_PEM: raise AcmeError("acme.invalid_pem_size") data = stream.read(MAX_PEM + 1) if len(data) > MAX_PEM or not data.startswith(b"-----BEGIN "): raise AcmeError("acme.invalid_pem") return data def issue(raw: dict, certificate_directory: Path, executable: str | None = None) -> dict: value = validate(raw) command_prefix = certbot_prefix(executable) certificate_directory = certificate_directory.absolute() private_directory(certificate_directory) root = certificate_directory / "acme" private_directory(root) # Each profile has isolated CA accounts, credentials and lineages. suffix = "" if value["provider"] == "rfc2136" else "-" + value["provider"] work = root / (value["environment"] + "-" + value["certificate_id"] + suffix) private_directory(work) if len(list(root.iterdir())) > 128: raise AcmeError("acme.profile_limit") import fcntl lock = os.open(work / ".lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600) with os.fdopen(lock, "w") as lock_file: try: fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: raise AcmeError("acme.profile_busy") from None for folder in ["config", "work", "logs"]: private_directory(work / folder) # Secret Store credentials exist as a plaintext file only while Certbot runs. credential_file = work / (value["provider"] + ".ini") if value["provider"] == "http01": webroot = certificate_directory / "acme-http01" for path in (webroot, webroot / ".well-known", webroot / ".well-known" / "acme-challenge"): private_directory(path) else: credential_file.unlink(missing_ok=True) write_new(credential_file, credentials(value)) try: profile = {k: v for k, v in value.items() if k not in SECRET_FIELDS} temporary = work / ("profile-" + uuid.uuid4().hex) write_new(temporary, json.dumps(profile).encode()) os.replace(temporary, work / "profile.json") args = certbot_command(value, work, credential_file, command_prefix) try: result = subprocess.run(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=1200 + value["propagation_seconds"], check=False) except subprocess.TimeoutExpired: raise AcmeError("acme.provider_timeout") from None if result.returncode: raise AcmeError("acme.certbot_failed_check_private_log") finally: if value["provider"] != "http01": credential_file.unlink(missing_ok=True) cert = read_generated(work, "fullchain.pem") key = read_generated(work, "privkey.pem") # Work on immutable copies so the active certificate never changes mid-run. stem = "acme-" + uuid.uuid4().hex cert_path = certificate_directory / (stem + ".pem") key_path = certificate_directory / (stem + ".key") if len(list(certificate_directory.glob("acme-*.key"))) >= 128: raise AcmeError("acme.staging_limit") try: write_new(key_path, key) write_new(cert_path, cert) except BaseException: cert_path.unlink(missing_ok=True) key_path.unlink(missing_ok=True) raise return {"provider": value["provider"], "certificate_id": value["certificate_id"], "environment": value["environment"], "domains": value["domains"], "cert_file": cert_path.name, "key_file": key_path.name, "staged": True} def profile_directory(certificate_directory: Path, provider: str, environment: str, certificate_id: str) -> Path: suffix = "" if provider == "rfc2136" else "-" + provider return certificate_directory / "acme" / (environment + "-" + certificate_id + suffix) def read_private(path: Path, limit: int = MAX_INPUT) -> bytes: fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) with os.fdopen(fd, "rb") as stream: info = os.fstat(stream.fileno()) if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077 or info.st_size > limit: raise AcmeError("acme.unsafe_profile") data = stream.read(limit + 1) if len(data) > limit: raise AcmeError("acme.unsafe_profile") return data def certificate_dates(pem: bytes, executable: str | None = None) -> dict: """Read validity without exposing subject/PEM data or requiring a new Python package.""" executable = executable or shutil.which("openssl") if not executable: raise AcmeError("acme.openssl_not_installed") try: result = subprocess.run([executable, "x509", "-noout", "-startdate", "-enddate"], input=pem, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10, check=False, env={**os.environ, "LC_ALL": "C"}) if result.returncode or len(result.stdout) > 4096: raise ValueError() dates = {} for line in result.stdout.decode("ascii").splitlines(): name, text = line.split("=", 1) # OpenSSL's English ASN.1 date print format; LC_ALL is fixed above. value = datetime.datetime.strptime(text.strip(), "%b %d %H:%M:%S %Y GMT").replace(tzinfo=datetime.timezone.utc) dates[name] = int(value.timestamp()) if dates["notAfter"] <= dates["notBefore"]: raise ValueError() return {"not_before_unix": dates["notBefore"], "not_after_unix": dates["notAfter"]} except (ValueError, KeyError, UnicodeError, subprocess.TimeoutExpired): raise AcmeError("acme.certificate_dates_invalid") from None def renewal_request(raw: object) -> dict: if not isinstance(raw, dict) or set(raw) - {"action", "certificate_id", "provider", "environment", "cert_file", "key_file", "dry_run", "credential_ini_b64"}: raise AcmeError("acme.invalid_renewal_request") if raw.get("action") != "renew" or raw.get("provider") not in PROVIDERS or raw.get("environment") not in DIRECTORIES: raise AcmeError("acme.invalid_renewal_request") if not isinstance(raw.get("certificate_id"), str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", raw["certificate_id"]): raise AcmeError("acme.invalid_id") for name, suffix in [("cert_file", ".pem"), ("key_file", ".key")]: value = raw.get(name) if not isinstance(value, str) or not re.fullmatch(r"acme-[A-Za-z0-9_-]{1,80}" + re.escape(suffix), value): raise AcmeError("acme.invalid_renewal_request") if type(raw.get("dry_run", False)) is not bool: raise AcmeError("acme.invalid_renewal_request") if raw["provider"] != "http01": encoded = raw.get("credential_ini_b64") try: credential_ini = base64.b64decode(encoded, validate=True) if isinstance(encoded, str) else b"" except (ValueError, TypeError): credential_ini = b"" if not credential_ini or len(credential_ini) > 65536 or b"\x00" in credential_ini: raise AcmeError("acme.invalid_api_credential") else: credential_ini = b"" value = dict(raw, dry_run=raw.get("dry_run", False)) value["credential_ini"] = credential_ini value.pop("credential_ini_b64", None) return value def renew(raw: dict, certificate_directory: Path, executable: str | None = None) -> dict: """Renew ONE existing, consented lineage; never force issuance or create a profile. Certbot decides when a certificate is due (including its ARI support). A zero exit status alone does not prove a renewal: compare actual certificate/key bytes with the installed generation, so crash-after-renewal is recoverable. Dry-run validates challenge access but never stages/deploys its test output. """ value = renewal_request(raw) command_prefix = certbot_prefix(executable) certificate_directory = certificate_directory.absolute() private_directory(certificate_directory) work = profile_directory(certificate_directory, value["provider"], value["environment"], value["certificate_id"]) # Do not create missing profile paths during renewal. for path in [certificate_directory / "acme", work, work / "config", work / "work", work / "logs"]: if not path.exists(): raise AcmeError("acme.renewal_profile_missing") private_directory(path) import fcntl lock = os.open(work / ".lock", os.O_WRONLY | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600) with os.fdopen(lock, "w") as lock_file: try: fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: raise AcmeError("acme.profile_busy") from None try: profile = json.loads(read_private(work / "profile.json")) except (OSError, ValueError): raise AcmeError("acme.renewal_profile_missing") from None if not isinstance(profile, dict) or any(profile.get(k) != value[k] for k in ("provider", "certificate_id", "environment")): raise AcmeError("acme.renewal_profile_mismatch") if profile.get("agree_tos") is not True or (value["environment"] == "production" and profile.get("confirm_production") is not True): raise AcmeError("acme.consent_required") names = profile.get("domains") if not isinstance(names, list) or not 1 <= len(names) <= 16: raise AcmeError("acme.invalid_domains") names = [domain(n, wildcard=value["provider"] != "http01") for n in names] # Credentials are recreated only for the duration of this invocation. read_private(work / "config" / "renewal" / "rustproxy.conf", 65536) credential_file = work / (value["provider"] + ".ini") if value["provider"] != "http01": credential_file.unlink(missing_ok=True) write_new(credential_file, value["credential_ini"]) else: for path in [certificate_directory / "acme-http01", certificate_directory / "acme-http01" / ".well-known", certificate_directory / "acme-http01" / ".well-known" / "acme-challenge"]: private_directory(path) try: installed_cert = read_private(certificate_directory / value["cert_file"], MAX_PEM) installed_key = read_private(certificate_directory / value["key_file"], MAX_PEM) # Check dependencies/installed PEM before invoking a CA operation. old_dates = certificate_dates(installed_cert) args = [*command_prefix, "renew", "--cert-name", "rustproxy", "--non-interactive", "--no-random-sleep-on-renew", "--no-directory-hooks", "--config-dir", str(work / "config"), "--work-dir", str(work / "work"), "--logs-dir", str(work / "logs")] if value["dry_run"]: args.append("--dry-run") try: result = subprocess.run(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2100, check=False) except subprocess.TimeoutExpired: raise AcmeError("acme.provider_timeout") from None if result.returncode: raise AcmeError("acme.certbot_failed_check_private_log") finally: if value["provider"] != "http01": credential_file.unlink(missing_ok=True) output = {k: value[k] for k in ("provider", "certificate_id", "environment")} output.update(domains=names, changed=False, staged=False, dry_run=value["dry_run"], **old_dates) if value["dry_run"]: return output cert, key = read_generated(work, "fullchain.pem"), read_generated(work, "privkey.pem") dates = certificate_dates(cert) now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) if dates["not_before_unix"] > now + 300 or dates["not_after_unix"] <= now + 300: raise AcmeError("acme.certificate_not_valid") if cert == installed_cert and key == installed_key: return output if dates["not_after_unix"] < old_dates["not_after_unix"]: raise AcmeError("acme.renewal_older_certificate") # Content-addressed staging keeps retries idempotent. stem = "acme-" + hashlib.sha256(cert + b"\0" + key).hexdigest()[:48] paths = [(certificate_directory / (stem + ".pem"), cert), (certificate_directory / (stem + ".key"), key)] if len(list(certificate_directory.glob("acme-*.key"))) >= 1024 and not paths[1][0].exists(): raise AcmeError("acme.staging_limit") for path, content in paths: try: write_new(path, content) except FileExistsError: if read_private(path, MAX_PEM) != content: raise AcmeError("acme.immutable_output_conflict") from None output.update(changed=True, staged=True, cert_file=paths[0][0].name, key_file=paths[1][0].name, **dates) return output def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--cert-dir", type=Path) parser.add_argument("--doctor", action="store_true") args = parser.parse_args() if args.doctor: print(json.dumps({"python": sys.version.split()[0], "certbot": shutil.which("certbot"), "providers": sorted(PROVIDERS), "required_plugins": ["certbot-dns-cloudflare", "certbot-dns-ovh", "certbot-dns-rfc2136"], "openssl": shutil.which("openssl"), "automatic_renewal": True, "network_checked": False})) return if not args.cert_dir: parser.error("--cert-dir is required") os.umask(0o077) try: raw = sys.stdin.buffer.read(MAX_INPUT + 1) if len(raw) > MAX_INPUT: raise AcmeError("acme.request_too_large") value = json.loads(raw) result = renew(value, args.cert_dir) if isinstance(value, dict) and value.get("action") == "renew" else issue(value, args.cert_dir) print(json.dumps(result)) except (AcmeError, OSError, ValueError) as error: # OSError/JSON errors can contain paths/input. Do not serialize them. message = str(error) if isinstance(error, AcmeError) else "acme.local_io_or_json_error" print(json.dumps({"error": message})) raise SystemExit(1) from None if __name__ == "__main__": main()