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>
267 lines
10 KiB
Python
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]
|