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>
368 lines
15 KiB
Python
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
|
|
]
|