diff --git a/README.md b/README.md index 5bbbde7..d24f777 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,11 @@ erzeugt ein Zertifikat für `vpn.example.com` **und** `*.vpn.example.com`. ## 1. Voraussetzungen * Docker (oder lokal Python 3.10+) -* Die Domain muss auf dem Plesk-Server als DNS-Zone liegen und Plesk muss der +* Die **Zone** muss auf dem Plesk-Server liegen und Plesk muss der **autoritative Nameserver** dafür sein (sonst kann die DNS-Challenge nicht validiert werden). + Der Name selbst braucht *kein* eigenes Abo und keine eigene Domain zu sein: für + `endian.fon-aria.de` reicht die Zone `fon-aria.de` – das Tool sucht sich die + passende Zone von unten nach oben zusammen und legt dort einfach die Records an. * Ein Plesk-API-Zugang (API-Key empfohlen). ### Plesk API-Key erzeugen @@ -88,6 +91,7 @@ python -m app.main vpn.example.com 192.0.2.10 |---|---| | `--ip ` | Alternative zum zweiten Positionsargument (IPv4 → A, IPv6 → AAAA) | | `--san ` | zusätzlicher Name im Zertifikat (mehrfach möglich) | +| `--zone ` | Plesk-Zone explizit angeben statt sie zu ermitteln | | `--no-wildcard` | nur der reine Name, ohne `*.` | | `--skip-dns` | A/AAAA-Record unangetastet lassen | | `--dns-only` | nur DNS, kein Zertifikat | @@ -130,7 +134,10 @@ openssl pkcs12 -info -in certs/vpn.example.com/cert.pfx -nodes -passin pass:` und `*.` erzeugen. @@ -155,7 +162,7 @@ Das Skript ist idempotent: Läuft es, obwohl das Zertifikat noch länger als | Symptom | Ursache / Lösung | |---|---| | `Plesk rejected the credentials (HTTP 401)` | API-Key falsch oder nicht für diese Quell-IP erzeugt | -| `No Plesk DNS zone found for …` | Domain liegt nicht auf diesem Plesk oder der API-User sieht sie nicht | +| `No Plesk DNS zone found for …` | Zone liegt nicht auf diesem Plesk oder der API-User sieht sie nicht. Die Meldung listet auf, welche Namen probiert wurden und was Plesk dazu gesagt hat. Notfalls die Zone mit `--zone example.com` fest vorgeben | | `DNS propagation timed out` | Plesk ist nicht der autoritative NS, oder die Zone ist deaktiviert. Mit `-v` prüfen, welcher Nameserver antwortet | | `no dns-01 challenge` | Wildcards gehen ausschließlich über DNS-01 – Domain-Validierung per HTTP ist nicht möglich | | `urn:ietf:params:acme:error:rateLimited` | Rate-Limit von Let's Encrypt (5 Zertifikate pro Woche pro Domain). Erst mit `--staging` testen | diff --git a/app/__init__.py b/app/__init__.py index 380badf..8c93f6f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,3 @@ """Wildcard Let's Encrypt certificate creator for Plesk-managed DNS zones.""" -__version__ = "1.0.0" +__version__ = "1.1.0" diff --git a/app/main.py b/app/main.py index 83674c3..589e325 100644 --- a/app/main.py +++ b/app/main.py @@ -95,6 +95,9 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--ip", dest="ip_opt", help="alternative to the positional IP argument") parser.add_argument("--san", action="append", default=[], help="additional SAN (repeatable)") + parser.add_argument("--zone", + help="name the Plesk DNS zone explicitly instead of detecting it " + "(e.g. --zone example.com for host.example.com)") parser.add_argument("--no-wildcard", action="store_true", help="only the plain name, without *.") parser.add_argument("--skip-dns", action="store_true", @@ -150,6 +153,7 @@ def run(args: argparse.Namespace) -> int: log.info("=" * 62) plesk = PleskClient(cfg) + plesk.zone_hint = args.zone zone, domain_id = plesk.find_zone(fqdn) log.info("Plesk DNS zone: %s (domain id %s)", zone, domain_id) diff --git a/app/plesk.py b/app/plesk.py index 370531d..e02f34e 100644 --- a/app/plesk.py +++ b/app/plesk.py @@ -69,7 +69,10 @@ class PleskClient: 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 --------------------------------------------------------- @@ -122,6 +125,7 @@ class PleskClient: 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") @@ -132,10 +136,14 @@ class PleskClient: 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") @@ -146,26 +154,86 @@ class PleskClient: 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 + 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 most specific Plesk zone hosting *fqdn*. Returns (zone_name, domain_id).""" + """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() - 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 ''}" + 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)." ) - zone = max(candidates, key=len) - return zone, zones[zone] + message.extend(f"Plesk said: {p}" for p in self._list_problems) + raise PleskError(" ".join(message)) # -- records ----------------------------------------------------------- @@ -180,9 +248,13 @@ class PleskClient: 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) + # 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: diff --git a/run.sh b/run.sh index 146bae2..a07c298 100755 --- a/run.sh +++ b/run.sh @@ -24,9 +24,8 @@ fi export PUID="${PUID:-$(id -u)}" export PGID="${PGID:-$(id -g)}" -if ! docker image inspect wildcard-lets-encrypt-cert-plesk-creator:latest >/dev/null 2>&1; then - echo ">> Baue Docker-Image ..." - $DC build -fi +# Immer bauen - ist bei unveraendertem Code dank Cache in ~1s durch und +# vermeidet, dass ein alter Stand im Image haengen bleibt. +$DC build -q >/dev/null exec $DC run --rm wildcard-cert "$@" diff --git a/tests/test_cli.py b/tests/test_cli.py index 280070a..d4a8852 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -42,15 +42,32 @@ ZONE.add("NS", "", "ns1.example.com.") ZONE.add("A", "vpn", "203.0.113.1") +# Wie beim echten Server von Stefan: die Zone haengt an einem Abo (webspace), +# 'site' kennt sie nicht, und dieser API-User darf ueberhaupt nichts auflisten. +SITE_ZONES = {} +WEBSPACE_ZONES = {"example.com": "3"} +ENUMERATION_ALLOWED = False + + def fake_request(self, body): tag = body.tag - if tag == "site": - return ET.fromstring( - 'ok3' - 'example.com' - ) - if tag == "webspace": - return ET.fromstring('') + if tag in ("site", "webspace"): + wanted = body.findtext("./get/filter/name") + if wanted is None and not ENUMERATION_ALLOWED: + # _request() wandelt einen -Fehler in eine PleskError um + raise pleskmod.PleskError(f"Plesk API error 1005: Permission denied ({tag}.get)") + zones = SITE_ZONES if tag == "site" else WEBSPACE_ZONES + selected = {n: i for n, i in zones.items() if wanted is None or n == wanted} + if selected: + inner = "".join( + f"ok{i}" + f"{n}" + for n, i in selected.items() + ) + else: + inner = ("error1013" + "Object not found") + return ET.fromstring(f"<{tag}>{inner}") if body.find("get_rec") is not None: parts = "".join( f'ok{r["id"]}3' @@ -151,6 +168,14 @@ rc = appmain.main(["www.example.com", "203.0.113.55", "--dns-only", "--env", "/d assert rc == 0 and not issued assert any(r["host"] == "www.example.com." and r["value"] == "203.0.113.55" for r in ZONE.records) +print("\n--- run 6: --zone erzwingt die Zone") +issued.clear() +rc = appmain.main(["endian.example.com", "203.0.113.77", "--zone", "example.com", + "--env", "/dev/null"]) +assert rc == 0 and issued["domains"] == ["endian.example.com", "*.endian.example.com"], issued +assert any(r["host"] == "endian.example.com." and r["value"] == "203.0.113.77" + for r in ZONE.records), ZONE.records + print("\n--- error paths") assert appmain.main(["vpn.example.com", "not-an-ip", "--env", "/dev/null"]) == 2 assert appmain.main(["vpn.otherdomain.tld", "203.0.113.1", "--env", "/dev/null"]) == 1 diff --git a/tests/test_offline.py b/tests/test_offline.py index fc2a09c..2f19fbe 100644 --- a/tests/test_offline.py +++ b/tests/test_offline.py @@ -40,14 +40,25 @@ except PleskError: print("host helpers ok") # ---- Plesk XML parsing with a stubbed transport --------------------------- -SITES = """ -ok3example.com -example.com -ok7sub.example.com -""" -WEBSPACES = """ -ok3example.com -""" +# Only these two exist on the fake Plesk; 'sub.example.com' is an addon domain +# with its own zone, everything else is just a record inside a zone. +SITE_ZONES = {"example.com": "3", "sub.example.com": "7"} +WEBSPACE_ZONES = {"example.com": "3"} + + +def domains_response(operator, zones, wanted): + """Mimic / get, with and without a name filter.""" + selected = {n: i for n, i in zones.items() if wanted is None or n == wanted} + if not selected: + body = ('error1013' + 'Object not found') + else: + body = "".join( + f'ok{i}' + f'{n}{n}' + for n, i in selected.items() + ) + return f"<{operator}>{body}" RECORDS = """ ok1013A vpn.example.com.10.0.0.1 @@ -68,10 +79,10 @@ class StubPlesk(PleskClient): def _request(self, body): payload = ET.tostring(body, encoding="unicode") calls.append(payload) - if body.tag == "site": - return ET.fromstring(SITES) - if body.tag == "webspace": - return ET.fromstring(WEBSPACES) + if body.tag in ("site", "webspace"): + wanted = body.findtext("./get/filter/name") + zones = SITE_ZONES if body.tag == "site" else WEBSPACE_ZONES + return ET.fromstring(domains_response(body.tag, zones, wanted)) if body.find("get_rec") is not None: return ET.fromstring(RECORDS) if body.find("add_rec") is not None: @@ -83,8 +94,29 @@ class StubPlesk(PleskClient): p = StubPlesk(cfg) assert p.list_zones() == {"example.com": "3", "sub.example.com": "7"}, p.list_zones() + +# A name that is only a record inside a zone must resolve to that zone ... assert p.find_zone("vpn.example.com") == ("example.com", "3") +assert p.find_zone("deep.down.example.com") == ("example.com", "3") +# ... while a real addon domain wins over its parent (most specific first). +assert p.find_zone("sub.example.com") == ("sub.example.com", "7") assert p.find_zone("x.sub.example.com") == ("sub.example.com", "7") +assert p.find_zone("example.com") == ("example.com", "3") + +# The zone lookup must not depend on being allowed to enumerate all domains. +enumerating = StubPlesk(cfg) +enumerating.list_zones = lambda: (_ for _ in ()).throw(AssertionError("enumerated all domains")) +assert enumerating.find_zone("vpn.example.com") == ("example.com", "3") + +# --zone overrides the detection +hinted = StubPlesk(cfg) +hinted.zone_hint = "example.com" +assert hinted.find_zone("x.sub.example.com") == ("example.com", "3") +try: + hinted.find_zone("vpn.other.tld") + raise SystemExit("FAIL: zone hint mismatch not detected") +except PleskError: + pass recs = p.get_records("3") assert [r.type for r in recs] == ["A", "NS", "TXT"] assert recs[2].value == "oldvalue", recs[2].value # quotes stripped