"""Minimal Plesk XML-API client with the DNS operations we need. Endpoint: https://: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 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 ]