diff --git a/docs/handoffs/2026-07-31-hetzner-dns-api-migration.md b/docs/handoffs/2026-07-31-hetzner-dns-api-migration.md new file mode 100644 index 0000000..54cd1bd --- /dev/null +++ b/docs/handoffs/2026-07-31-hetzner-dns-api-migration.md @@ -0,0 +1,84 @@ +# Hetzner-DNS: die alte API ist abgeschaltet + +**Gemessen am 31. Juli 2026, am Live-Server und von außen.** + +``` +https://dns.hetzner.com/api/v1/zones → 301 → https://console.hetzner.com/ (HTML) +https://api.hetzner.cloud/v1/zones → 401 ohne Token, 200 mit dem Token des Betreibers +``` + +Hetzner hat die DNS-Verwaltung am 7. Oktober 2025 in die Cloud-Konsole verlegt. +Die alte DNS-Konsole ging am **20. Mai 2026** in den Nur-Lese-Betrieb; seither +leitet der alte Endpunkt auf die Weboberfläche um. **Alte Token und Zonen-IDs +sind mit der neuen Konsole nicht kompatibel** und wurden nicht übernommen. + +## Was dadurch kaputt ist + +- `App\Services\Dns\DnsTokenCheck` — meldet `zone_not_found`, obwohl Token und + Zone in Ordnung sind. Seit v1.3.77 meldet es immerhin ehrlich + `zone_list_unreadable` mit dem Anfang der HTML-Antwort. +- `App\Services\Dns\HttpHetznerDnsClient` — **das ist das Teure.** Er legt den + A-Eintrag jeder Kundeninstanz an. Solange er auf den alten Endpunkt zeigt, + stirbt jede Bereitstellung in `ConfigureDnsAndTls` — nachdem der Kunde bezahlt + hat. Genau der Satz steht als Begründung auf der Bereitschaftsseite. +- Der Token des Betreibers ist bereits ein Cloud-Token und antwortet mit 200. + Es fehlt **nur** der Code. + +## Die neue Schnittstelle + +Aus `https://docs.hetzner.cloud/cloud.spec.json` gezogen, nicht geraten. + +**Anmeldung:** `Authorization: Bearer ` — nicht mehr `Auth-API-Token`. + +**Basis:** `https://api.hetzner.cloud/v1` + +| Zweck | Aufruf | +|---|---| +| Zonen auflisten | `GET /zones` → `{"zones":[{id,name,…}], "meta":{…}}` | +| Eine Zone direkt | `GET /zones/{id_or_name}` — **der Name geht direkt**, kein Suchen mehr | +| Eintragssatz anlegen | `POST /zones/{zone}/rrsets` | +| Eintragssatz ersetzen | `POST /zones/{zone}/rrsets/{name}/{type}/actions/set_records` | +| Eintragssatz löschen | `DELETE /zones/{zone}/rrsets/{name}/{type}` | + +**Das Modell hat sich geändert:** keine einzelnen Records mehr, sondern +**RRSets**, adressiert über `{name}/{typ}`. Ein RRSet trägt mehrere Werte. +`set_records` ersetzt den ganzen Satz — damit wird das Schreiben idempotent, was +der alte Client mit Suchen-und-Aktualisieren nachbauen musste. + +Rumpf beim Anlegen, Pflichtfelder `name`, `type`, `records`: + +```json +{ "name": "kunde", "type": "A", "ttl": 60, + "records": [{"value": "203.0.113.10"}] } +``` + +**Zwei Fallen im `name`:** + +1. Er darf **nicht** auf den Zonennamen enden. `kunde`, nicht + `kunde.clupilot.cloud` — der alte Client hat den vollen Namen geschickt. +2. Für die Zonenwurzel ist es `@`, und er muss klein geschrieben sein. + +`PUT /zones/{zone}/rrsets/{name}/{type}` ist **nicht** das Schreiben der Werte — +es setzt nur `labels`. Wer dort die Records erwartet, schreibt ins Leere. + +## Was zu tun ist + +1. `HttpHetznerDnsClient` auf Basis, Kopfzeile und RRSets umstellen. Der + Zonen-Lookup entfällt: der Name geht direkt in den Pfad. +2. `DnsTokenCheck` dito. Die Schreibprobe bleibt der Kern der Prüfung — ein + lesender Token sieht sonst genauso aus wie ein schreibender, und der + Unterschied fällt erst auf, wenn ein Kunde bezahlt hat. +3. `FakeHetznerDnsClient` und die Tests nachziehen. +4. Den Namen ohne Zonensuffix bilden — Falle 1 oben. Ein Test, der genau das + festhält, ist billiger als der erste Kunde, dessen Adresse + `kunde.clupilot.cloud.clupilot.cloud` heißt. + +## Wieso das so lange gesucht wurde + +Die Prüfung stufte nur 401 und 403 als abgelehnt ein und las danach den Rumpf. +Jede andere Antwort — hier eine 301 auf eine HTML-Seite — hatte kein `zones`, +und `?? []` machte daraus „keine Zone in diesem Konto". Eine Aussage über ein +fremdes Hetzner-Konto, hergeleitet aus einer Antwort, die niemand angesehen hat. +v1.3.76 und v1.3.77 haben das behoben; die Meldung zeigt jetzt Status und die +ersten Zeichen der Antwort. Ohne diese zwei Schritte wäre die Ursache weiter im +Dunkeln.