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:
+295
@@ -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
|
||||
]
|
||||
Reference in New Issue
Block a user