Files
RustProxy/scripts/runtime/acme_provider.py
T
gru eba9f89283 v0.35.1: performance, AGPL licensing and documentation overhaul
- Web Server reads static files inline; proxy middleware chain flattened
- About window with license summary and project links; AGPL-3.0-or-later
- Rewritten documentation: feature list, screenshots, performance guide
- Fix false spikes in per-Vhost request-rate charts
2026-11-05 18:29:56 +01:00

474 lines
23 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:
# 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()