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>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user