474 lines
24 KiB
Python
Executable File
474 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_command(value: dict, work: Path, credential_file: Path, executable: str) -> list[str]:
|
|
args = [executable, "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)
|
|
executable = executable or shutil.which("certbot")
|
|
if not executable:
|
|
raise AcmeError("acme.certbot_not_installed")
|
|
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, executable)
|
|
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)
|
|
executable = executable or shutil.which("certbot")
|
|
if not executable:
|
|
raise AcmeError("acme.certbot_not_installed")
|
|
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 = [executable, "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()
|