Files
wildcard-lets-encrypt-cert-…/app/plesk.py
T
duffyduckandClaude Opus 5 611593b5c0 Zone gezielt bei Plesk erfragen statt alle Domains aufzulisten
Ein Name wie endian.fon-aria.de ist oft nur ein Record in der Zone
fon-aria.de und existiert weder als Domain noch als Abo. Ausserdem darf
nicht jeder API-User alle Domains des Servers auflisten - dann brach das
Tool mit "No domains found on the Plesk server" ab, obwohl die Zone da war.

Die Zone wird jetzt Label fuer Label von unten nach oben gezielt abgefragt
(endian.fon-aria.de -> fon-aria.de), jeweils per site.get und webspace.get
mit Namensfilter. Das Auflisten aller Domains dient nur noch der
Fehlermeldung, die jetzt auch zeigt, welche Namen probiert wurden und was
Plesk dazu gesagt hat.

- neue Option --zone, um die Zone bei Bedarf fest vorzugeben
- get_rec meldet jetzt als Warnung, wenn Plesk keine Records liefert
- run.sh baut das Image immer (Cache), damit kein alter Stand haengen bleibt
- Tests bilden den Fall nach: Zone nur als Abo auffindbar, Auflisten verboten

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

368 lines
15 KiB
Python

"""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_hint: str | None = None
self._zone_cache: dict[str, str] | None = None
self._list_problems: list[str] = []
self._fqdn_zone_cache: dict[str, tuple[str, str]] = {}
# -- 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] = {}
problems: list[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)
problems.append(f"{operator}.get: {exc}")
continue
for result in root.findall(f"./{operator}/get/result"):
if result.findtext("status") != "ok":
errtext = result.findtext("errtext")
if errtext:
problems.append(f"{operator}.get: {errtext}")
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)
self._zone_cache = zones
self._list_problems = problems
log.debug("Known Plesk zones: %s", sorted(zones))
return zones
def _domain_id_by_name(self, name: str) -> str | None:
"""Ask Plesk directly for one domain/subscription. None if it does not exist."""
for operator in ("site", "webspace"):
op = ET.Element(operator)
get = ET.SubElement(op, "get")
flt = ET.SubElement(get, "filter")
ET.SubElement(flt, "name").text = name
dataset = ET.SubElement(get, "dataset")
ET.SubElement(dataset, "gen_info")
try:
root = self._request(op)
except PleskError as exc:
log.debug("%s.get(%s) failed: %s", operator, name, exc)
continue
for result in root.findall(f"./{operator}/get/result"):
if result.findtext("status") == "ok":
domain_id = result.findtext("id")
if domain_id:
log.debug("Plesk %s %r has id %s", operator, name, domain_id)
return domain_id
else:
log.debug("%s.get(%s): %s", operator, name, result.findtext("errtext", ""))
return None
def find_zone(self, fqdn: str) -> tuple[str, str]:
"""Find the Plesk zone hosting *fqdn*. Returns (zone_name, domain_id).
Asks Plesk for each candidate name from the most specific one upwards
(``a.b.example.com`` -> ``b.example.com`` -> ``example.com``), so a name
that only exists as a DNS record inside a zone works as well. Enumerating
every domain on the server is not required - and often not permitted.
"""
fqdn = normalise_name(fqdn)
cached = self._fqdn_zone_cache.get(fqdn)
if cached:
return cached
if self.zone_hint:
zone = normalise_name(self.zone_hint)
if fqdn != zone and not fqdn.endswith("." + zone):
raise PleskError(f"{fqdn!r} is not inside the zone {zone!r} given via --zone.")
domain_id = self._domain_id_by_name(zone)
if not domain_id:
raise PleskError(f"Plesk does not know a domain or subscription named {zone!r}.")
self._fqdn_zone_cache[fqdn] = (zone, domain_id)
return zone, domain_id
labels = fqdn.split(".")
tried: list[str] = []
for index in range(len(labels) - 1):
candidate = ".".join(labels[index:])
tried.append(candidate)
domain_id = self._domain_id_by_name(candidate)
if domain_id:
if candidate != fqdn:
log.info("%s is a record inside the Plesk zone %s", fqdn, candidate)
self._fqdn_zone_cache[fqdn] = (candidate, domain_id)
return candidate, domain_id
# Nothing matched - build the most helpful error we can.
message = [
f"No Plesk DNS zone found for {fqdn!r}.",
f"Tried: {', '.join(tried)}.",
]
zones = self.list_zones()
if zones:
message.append(f"Domains visible to this API user: {', '.join(sorted(zones))}.")
else:
message.append(
"This API user cannot see any domain on the server either "
"(use --zone <zone> to name the zone explicitly)."
)
message.extend(f"Plesk said: {p}" for p in self._list_problems)
raise PleskError(" ".join(message))
# -- 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":
# A zone without records reports an error instead of an empty list -
# worth a warning, since a healthy zone always has SOA/NS entries.
log.warning(
"Plesk returned no DNS records for domain id %s: %s",
domain_id,
result.findtext("errtext", "unknown reason"),
)
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
]