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:
duffyduck
2026-08-13 09:41:26 +02:00
co-authored by Claude Opus 5
commit fd76c18eb0
17 changed files with 2080 additions and 0 deletions
+295
View File
@@ -0,0 +1,295 @@
"""Minimal Plesk XML-API client with the DNS operations we need.
Endpoint: https://<host>:8443/enterprise/control/agent.php
Auth: KEY header (API secret key) or HTTP_AUTH_LOGIN / HTTP_AUTH_PASSWD.
"""
from __future__ import annotations
import ipaddress
import logging
import xml.etree.ElementTree as ET
from dataclasses import dataclass
import requests
import urllib3
from .config import Config
log = logging.getLogger(__name__)
class PleskError(Exception):
"""Raised when Plesk answers with an error status or unparsable data."""
@dataclass(frozen=True)
class DnsRecord:
id: str
site_id: str
type: str
host: str # normalised: lower case, no trailing dot
value: str
def __str__(self) -> str: # pragma: no cover - debug helper
return f"[{self.id}] {self.type} {self.host} -> {self.value}"
def normalise_name(name: str) -> str:
return name.strip().rstrip(".").lower()
def plesk_host(fqdn: str, zone: str) -> str:
"""Host value for add_rec: relative label, or the absolute name for the zone apex."""
relative = relative_host(fqdn, zone)
return relative if relative else normalise_name(fqdn) + "."
def relative_host(fqdn: str, zone: str) -> str:
"""Return the host part of *fqdn* relative to *zone* ('' for the zone apex)."""
fqdn = normalise_name(fqdn)
zone = normalise_name(zone)
if fqdn == zone:
return ""
if not fqdn.endswith("." + zone):
raise PleskError(f"{fqdn!r} is not inside the DNS zone {zone!r}.")
return fqdn[: -(len(zone) + 1)]
class PleskClient:
def __init__(self, cfg: Config):
self.cfg = cfg
self.session = requests.Session()
headers = {"Content-Type": "text/xml", "HTTP_PRETTY_PRINT": "TRUE"}
if cfg.plesk_api_key:
headers["KEY"] = cfg.plesk_api_key
else:
headers["HTTP_AUTH_LOGIN"] = cfg.plesk_user
headers["HTTP_AUTH_PASSWD"] = cfg.plesk_password
self.session.headers.update(headers)
if not cfg.plesk_verify_tls:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self._zone_cache: dict[str, str] | None = None
# -- low level ---------------------------------------------------------
def _request(self, body: ET.Element) -> ET.Element:
packet = ET.Element("packet")
packet.append(body)
payload = ET.tostring(packet, encoding="utf-8", xml_declaration=True)
log.debug("Plesk request: %s", payload.decode("utf-8", "replace"))
try:
resp = self.session.post(
self.cfg.plesk_url,
data=payload,
verify=self.cfg.plesk_verify_tls,
timeout=self.cfg.plesk_timeout,
)
except requests.RequestException as exc:
raise PleskError(f"Cannot reach Plesk at {self.cfg.plesk_url}: {exc}") from exc
if resp.status_code == 401:
raise PleskError("Plesk rejected the credentials (HTTP 401). Check PLESK_API_KEY / PLESK_USER.")
if resp.status_code >= 400:
raise PleskError(f"Plesk returned HTTP {resp.status_code}: {resp.text[:500]}")
log.debug("Plesk response: %s", resp.text)
try:
root = ET.fromstring(resp.content)
except ET.ParseError as exc:
raise PleskError(f"Invalid XML from Plesk: {exc}\n{resp.text[:500]}") from exc
system_status = root.find("./system/status")
if system_status is not None and system_status.text != "ok":
errtext = root.findtext("./system/errtext", default="unknown error")
errcode = root.findtext("./system/errcode", default="?")
raise PleskError(f"Plesk API error {errcode}: {errtext}")
return root
@staticmethod
def _check_result(result: ET.Element, context: str) -> None:
status = result.findtext("status", default="")
if status != "ok":
errcode = result.findtext("errcode", default="?")
errtext = result.findtext("errtext", default="unknown error")
raise PleskError(f"{context} failed (code {errcode}): {errtext}")
# -- zones -------------------------------------------------------------
def list_zones(self) -> dict[str, str]:
"""Map of DNS zone name -> Plesk domain id, for domains and subscriptions."""
if self._zone_cache is not None:
return self._zone_cache
zones: dict[str, str] = {}
for operator in ("site", "webspace"):
op = ET.Element(operator)
get = ET.SubElement(op, "get")
ET.SubElement(get, "filter")
dataset = ET.SubElement(get, "dataset")
ET.SubElement(dataset, "gen_info")
try:
root = self._request(op)
except PleskError as exc:
log.debug("%s.get failed: %s", operator, exc)
continue
for result in root.findall(f"./{operator}/get/result"):
if result.findtext("status") != "ok":
continue
domain_id = result.findtext("id")
gen_info = result.find("./data/gen_info")
if domain_id is None or gen_info is None:
continue
for tag in ("name", "ascii-name"):
name = gen_info.findtext(tag)
if name:
zones.setdefault(normalise_name(name), domain_id)
if not zones:
raise PleskError(
"No domains found on the Plesk server. Does the API user have access to any subscription?"
)
self._zone_cache = zones
log.debug("Known Plesk zones: %s", sorted(zones))
return zones
def find_zone(self, fqdn: str) -> tuple[str, str]:
"""Find the most specific Plesk zone hosting *fqdn*. Returns (zone_name, domain_id)."""
fqdn = normalise_name(fqdn)
zones = self.list_zones()
candidates = [z for z in zones if fqdn == z or fqdn.endswith("." + z)]
if not candidates:
raise PleskError(
f"No Plesk DNS zone found for {fqdn!r}. "
f"Known zones: {', '.join(sorted(zones)) or '<none>'}"
)
zone = max(candidates, key=len)
return zone, zones[zone]
# -- records -----------------------------------------------------------
def get_records(self, domain_id: str) -> list[DnsRecord]:
dns = ET.Element("dns")
get_rec = ET.SubElement(dns, "get_rec")
flt = ET.SubElement(get_rec, "filter")
ET.SubElement(flt, "site-id").text = str(domain_id)
root = self._request(dns)
records: list[DnsRecord] = []
for result in root.findall("./dns/get_rec/result"):
status = result.findtext("status")
if status != "ok":
errtext = result.findtext("errtext", "")
# An empty zone reports an error instead of an empty list on some versions.
log.debug("get_rec result not ok: %s", errtext)
continue
data = result.find("data")
if data is None:
continue
records.append(
DnsRecord(
id=result.findtext("id", ""),
site_id=data.findtext("site-id", str(domain_id)),
type=(data.findtext("type", "") or "").upper(),
host=normalise_name(data.findtext("host", "") or ""),
value=(data.findtext("value", "") or "").strip().strip('"'),
)
)
return records
def add_record(self, domain_id: str, rtype: str, host: str, value: str) -> str:
dns = ET.Element("dns")
add_rec = ET.SubElement(dns, "add_rec")
ET.SubElement(add_rec, "site-id").text = str(domain_id)
ET.SubElement(add_rec, "type").text = rtype.upper()
ET.SubElement(add_rec, "host").text = host
ET.SubElement(add_rec, "value").text = value
root = self._request(dns)
result = root.find("./dns/add_rec/result")
if result is None:
raise PleskError("Unexpected Plesk answer while adding a DNS record.")
self._check_result(result, f"Adding {rtype} record {host or '@'}")
rec_id = result.findtext("id", "")
log.info("Plesk: added %s record %s -> %s (id %s)", rtype.upper(), host or "@", value, rec_id)
return rec_id
def delete_record(self, record_id: str) -> None:
dns = ET.Element("dns")
del_rec = ET.SubElement(dns, "del_rec")
flt = ET.SubElement(del_rec, "filter")
ET.SubElement(flt, "id").text = str(record_id)
root = self._request(dns)
result = root.find("./dns/del_rec/result")
if result is None:
raise PleskError("Unexpected Plesk answer while deleting a DNS record.")
self._check_result(result, f"Deleting DNS record {record_id}")
log.info("Plesk: deleted DNS record id %s", record_id)
# -- high level helpers ------------------------------------------------
def ensure_address_record(self, fqdn: str, ip: str) -> str:
"""Create or update the A/AAAA record for *fqdn*.
Returns 'created' | 'updated' | 'unchanged'.
"""
rtype = "AAAA" if ipaddress.ip_address(ip).version == 6 else "A"
zone, domain_id = self.find_zone(fqdn)
host = plesk_host(fqdn, zone)
fqdn_n = normalise_name(fqdn)
existing = [r for r in self.get_records(domain_id) if r.type == rtype and r.host == fqdn_n]
if any(r.value == ip for r in existing):
for stale in (r for r in existing if r.value != ip):
self.delete_record(stale.id)
log.info("DNS: %s record %s already points to %s", rtype, fqdn_n, ip)
return "unchanged"
for stale in existing:
log.info("DNS: replacing %s record %s (%s -> %s)", rtype, fqdn_n, stale.value, ip)
self.delete_record(stale.id)
self.add_record(domain_id, rtype, host, ip)
return "updated" if existing else "created"
def add_txt_record(self, fqdn: str, value: str) -> tuple[str, str]:
"""Add a TXT record. Returns (record_id, zone)."""
zone, domain_id = self.find_zone(fqdn)
host = plesk_host(fqdn, zone)
rec_id = self.add_record(domain_id, "TXT", host, value)
return rec_id, zone
def delete_txt_records(self, fqdn: str, value: str | None = None) -> int:
"""Delete TXT records for *fqdn* (optionally only those carrying *value*)."""
zone, domain_id = self.find_zone(fqdn)
fqdn_n = normalise_name(fqdn)
deleted = 0
for rec in self.get_records(domain_id):
if rec.type != "TXT" or rec.host != fqdn_n:
continue
if value is not None and rec.value != value:
continue
try:
self.delete_record(rec.id)
deleted += 1
except PleskError as exc:
log.warning("Could not delete TXT record %s: %s", rec.id, exc)
return deleted
def zone_has_txt(self, fqdn: str, value: str) -> bool:
_zone, domain_id = self.find_zone(fqdn)
fqdn_n = normalise_name(fqdn)
return any(
r.type == "TXT" and r.host == fqdn_n and r.value == value
for r in self.get_records(domain_id)
)
def nameservers(self, fqdn: str) -> list[str]:
"""NS records of the zone as configured in Plesk (best effort)."""
zone, domain_id = self.find_zone(fqdn)
zone_n = normalise_name(zone)
return [
normalise_name(r.value)
for r in self.get_records(domain_id)
if r.type == "NS" and r.host == zone_n
]