Files
RustProxy/scripts/acme_provider.py
T
2026-10-07 15:49:44 +02:00

483 lines
24 KiB
Python
Executable File

#!/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:
# All supported API keys are ASCII tokens. Reject whitespace/newlines and
# INI metacharacters; provider input must never add options or sections.
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_python = os.environ.get("RUSTPROXY_CERTBOT_PYTHON")
if managed_python:
managed_certbot = str(Path(managed_python).with_name("certbot"))
return [managed_python, "-I", 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:
# Certbot live entries are symlinks into its private archive. Resolve only
# within that archive, then open without following a second/final symlink.
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 identity/environment has isolated CA accounts, DNS credentials and
# Certbot lineages. Both profile IDs and directory paths are operator-owned.
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)
# DNS credentials are supplied by RustProxy's encrypted Secret Store.
# Certbot needs a file path, but the plaintext file exists only while the
# provider command runs and is deleted before this worker returns.
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")
# Immutable copies allow validation/publication without changing the
# active certificate during CA work or a concurrent configuration edit.
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]
# The renewal config persists, but provider credentials do not. Recreate
# the exact path referenced by Certbot only for 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 immutable staging makes a retry after a publication
# failure idempotent (no additional orphan PEMs each time).
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()