Files
wildcard-lets-encrypt-cert-…/app/acme_client.py
T
duffyduckandClaude Opus 5 fd76c18eb0 Wildcard-Zertifikate ueber Plesk-DNS und Let's Encrypt
Legt den A/AAAA-Record fuer einen Namen in Plesk an bzw. aktualisiert ihn und
holt anschliessend per DNS-01-Challenge ein Wildcard-Zertifikat von Let's
Encrypt. Alle Bestandteile werden einzeln abgelegt, zusaetzlich als
kombinierte bundle.pem und als passwortgeschuetzte cert.pfx.

- Plesk-Anbindung ueber die XML-API (Zone finden, Records lesen/anlegen/loeschen)
- ACME-Order mit dns-01, Account-Key wird wiederverwendet
- Propagations-Check gegen die autoritativen Nameserver der Zone, bricht vor
  der Validierung ab statt einen Fehlversuch bei Let's Encrypt zu verbrennen
- Renewal-Check: laeuft idempotent, taugt so direkt fuer den Cron
- Docker-Container, Konfiguration ueber .env, Ausgabe im Projektverzeichnis
- Tests ohne echten Plesk-Server bzw. ohne Zertifikatsausstellung

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 09:41:26 +02:00

267 lines
10 KiB
Python

"""ACME (Let's Encrypt) client: account handling, dns-01 orders, certificate issuance."""
from __future__ import annotations
import datetime
import json
import logging
import re
from collections import defaultdict
from pathlib import Path
from typing import Protocol
from urllib.parse import urlparse
import josepy as jose
from acme import challenges, client, errors, messages
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.x509.oid import NameOID
from .config import Config
log = logging.getLogger(__name__)
USER_AGENT = "wildcard-lets-encrypt-cert-plesk-creator/1.0"
ACCOUNT_KEY_BITS = 2048
_CURVES = {
"secp256r1": ec.SECP256R1,
"prime256v1": ec.SECP256R1,
"p-256": ec.SECP256R1,
"secp384r1": ec.SECP384R1,
"p-384": ec.SECP384R1,
}
class AcmeFailure(Exception):
"""Raised when the certificate could not be issued."""
class DnsSolver(Protocol):
"""Everything the ACME flow needs from the DNS side."""
def add_txt(self, name: str, value: str) -> None: ...
def wait_for_propagation(self, expected: dict[str, set[str]]) -> None: ...
def cleanup(self) -> None: ...
# --------------------------------------------------------------------------
# keys & CSR
# --------------------------------------------------------------------------
def generate_private_key(cfg: Config):
if cfg.key_type == "ec":
curve = _CURVES.get(cfg.ec_curve)
if curve is None:
raise AcmeFailure(f"Unsupported EC_CURVE {cfg.ec_curve!r} (use secp256r1 or secp384r1).")
log.info("Generating EC private key (%s)", cfg.ec_curve)
return ec.generate_private_key(curve())
log.info("Generating RSA private key (%d bit)", cfg.rsa_key_size)
return rsa.generate_private_key(public_exponent=65537, key_size=cfg.rsa_key_size)
def build_csr(private_key, domains: list[str]) -> bytes:
"""PEM-encoded CSR with *domains* as SANs (first entry also becomes the CN)."""
common_name = next((d for d in domains if not d.startswith("*.")), domains[0])
builder = (
x509.CertificateSigningRequestBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]))
.add_extension(
x509.SubjectAlternativeName([x509.DNSName(d) for d in domains]),
critical=False,
)
)
csr = builder.sign(private_key, hashes.SHA256())
return csr.public_bytes(serialization.Encoding.PEM)
# --------------------------------------------------------------------------
# ACME
# --------------------------------------------------------------------------
class AcmeManager:
def __init__(self, cfg: Config):
self.cfg = cfg
host = urlparse(cfg.acme_directory_url).hostname or "acme"
self.account_dir = cfg.acme_account_dir / re.sub(r"[^a-z0-9.-]", "_", host.lower())
self.account_dir.mkdir(parents=True, exist_ok=True)
self.account_key_file = self.account_dir / "account_key.json"
self.account_regr_file = self.account_dir / "account.json"
self.account_key = self._load_or_create_account_key()
self.client = self._connect()
# -- account -----------------------------------------------------------
def _load_or_create_account_key(self) -> jose.JWKRSA:
if self.account_key_file.is_file():
log.info("Using existing ACME account key %s", self.account_key_file)
return jose.JWKRSA.json_loads(self.account_key_file.read_text())
log.info("Creating new ACME account key in %s", self.account_key_file)
key = jose.JWKRSA(
key=rsa.generate_private_key(public_exponent=65537, key_size=ACCOUNT_KEY_BITS)
)
self.account_key_file.write_text(key.json_dumps_pretty())
self.account_key_file.chmod(0o600)
return key
def _connect(self) -> client.ClientV2:
net = client.ClientNetwork(self.account_key, user_agent=USER_AGENT)
url = self.cfg.acme_directory_url
log.info("Connecting to ACME directory %s", url)
try:
if hasattr(client.ClientV2, "get_directory"):
directory = client.ClientV2.get_directory(url, net)
else: # pragma: no cover - older acme releases
directory = messages.Directory.from_json(net.get(url).json())
except Exception as exc: # noqa: BLE001 - network/protocol errors alike
raise AcmeFailure(f"Cannot read the ACME directory at {url}: {exc}") from exc
acme_client = client.ClientV2(directory, net=net)
self._register(acme_client)
return acme_client
def _register(self, acme_client: client.ClientV2) -> None:
if self.account_regr_file.is_file():
try:
regr = messages.RegistrationResource.from_json(
json.loads(self.account_regr_file.read_text())
)
acme_client.net.account = regr
log.info("Reusing ACME account %s", regr.uri)
return
except Exception as exc: # noqa: BLE001 - fall back to registration
log.warning("Stored ACME account is unusable (%s), registering again.", exc)
log.info("Registering ACME account for %s", self.cfg.acme_email)
try:
regr = acme_client.new_account(
messages.NewRegistration.from_data(
email=self.cfg.acme_email, terms_of_service_agreed=True
)
)
except errors.Error as exc:
raise AcmeFailure(f"ACME account registration failed: {exc}") from exc
self.account_regr_file.write_text(json.dumps(regr.to_json(), indent=2))
self.account_regr_file.chmod(0o600)
log.info("ACME account registered: %s", regr.uri)
# -- issuance ----------------------------------------------------------
def obtain_certificate(self, domains: list[str], csr_pem: bytes, solver: DnsSolver) -> str:
log.info("Requesting certificate for: %s", ", ".join(domains))
try:
order = self.client.new_order(csr_pem)
except errors.Error as exc:
raise AcmeFailure(f"ACME order could not be created: {exc}") from exc
pending: list[tuple[messages.ChallengeBody, str, str]] = []
expected: dict[str, set[str]] = defaultdict(set)
for authz in order.authorizations:
identifier = authz.body.identifier.value
if authz.body.status == messages.STATUS_VALID:
log.info("Authorization for %s is still valid - no challenge needed.", identifier)
continue
challb = self._dns_challenge(authz, identifier)
validation = challb.chall.validation(self.account_key)
record_name = challb.chall.validation_domain_name(identifier)
expected[record_name].add(validation)
pending.append((challb, record_name, validation))
if not pending:
log.info("All authorizations are already valid.")
try:
for record_name, validation in ((n, v) for n, vs in expected.items() for v in vs):
solver.add_txt(record_name, validation)
if expected:
solver.wait_for_propagation(dict(expected))
for challb, record_name, _validation in pending:
log.info("Answering dns-01 challenge for %s", record_name)
self.client.answer_challenge(challb, challb.chall.response(self.account_key))
deadline = datetime.datetime.now() + datetime.timedelta(seconds=300)
try:
order = self.client.poll_and_finalize(order, deadline)
except errors.ValidationError as exc:
raise AcmeFailure(self._validation_error_text(exc)) from exc
except errors.TimeoutError as exc:
raise AcmeFailure(
"Timed out waiting for Let's Encrypt to validate/issue the certificate."
) from exc
except errors.Error as exc:
raise AcmeFailure(f"ACME finalization failed: {exc}") from exc
finally:
solver.cleanup()
if not order.fullchain_pem:
raise AcmeFailure("Let's Encrypt returned an empty certificate chain.")
log.info("Certificate issued.")
return order.fullchain_pem
@staticmethod
def _dns_challenge(authz, identifier: str) -> messages.ChallengeBody:
for challb in authz.body.challenges:
if isinstance(challb.chall, challenges.DNS01):
return challb
raise AcmeFailure(
f"Let's Encrypt offered no dns-01 challenge for {identifier} - "
"a wildcard certificate cannot be issued without it."
)
@staticmethod
def _validation_error_text(exc: errors.ValidationError) -> str:
lines = ["Let's Encrypt could not validate the DNS challenge:"]
for authz in exc.failed_authzrs:
domain = authz.body.identifier.value
for challb in authz.body.challenges:
if challb.error is not None:
lines.append(f" - {domain}: {challb.error.detail or challb.error}")
break
else:
lines.append(f" - {domain}: status {authz.body.status}")
lines.append(
"Check that the _acme-challenge TXT records are served by the authoritative "
"nameservers of the zone (is Plesk the DNS master for this domain?)."
)
return "\n".join(lines)
def certificate_domains(fqdn: str, wildcard: bool, extra_sans: list[str]) -> list[str]:
"""Build the SAN list: base name, *.base name, plus anything extra."""
domains = [fqdn]
if wildcard:
domains.append(f"*.{fqdn}")
for san in extra_sans:
san = san.strip().lower()
if san and san not in domains:
domains.append(san)
return domains
def csr_for(cfg: Config, domains: list[str]):
key = generate_private_key(cfg)
csr = build_csr(key, domains)
log.debug("CSR built for %s", domains)
return key, csr
def parse_chain(fullchain_pem: str) -> list[x509.Certificate]:
"""Split a PEM chain into certificate objects (leaf first)."""
pem_blocks = re.findall(
r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----",
fullchain_pem,
re.DOTALL,
)
if not pem_blocks:
raise AcmeFailure("No certificates found in the ACME response.")
return [x509.load_pem_x509_certificate(block.encode()) for block in pem_blocks]