*/ public function run(?string $candidate = null): array { $token = $candidate ?: app(SecretVault::class)->get('dns.token'); $zone = ProvisioningSettings::dnsZone(); if (blank($token) || blank($zone)) { return ['ok' => false, 'reason' => 'missing']; } $http = fn () => Http::withToken((string) $token)->acceptJson()->timeout(15) ->baseUrl('https://api.hetzner.cloud/v1'); try { $zones = $http()->get('/zones'); } catch (Throwable) { return ['ok' => false, 'reason' => 'unreachable']; } if ($zones->status() === 401 || $zones->status() === 403) { return ['ok' => false, 'reason' => 'rejected']; } // Erst der Erfolg, DANN der Rumpf. // // Ohne diese Zeile wurde jede nicht klassifizierte Fehlerantwort — 404, // 429, 500, eine Statusseite eines Zwischenspeichers — zu einer leeren // Zonenliste: der Rumpf hat kein `zones`, `?? []` macht daraus keine // Zonen, und die Anzeige behauptete daraufhin „in diesem Konto liegt // keine einzige Zone". Eine Aussage über ein fremdes Konto, hergeleitet // aus einem Fehler, den niemand angesehen hat. // // Das ist derselbe Fehler, den diese Prüfung an anderer Stelle // vermeidet: `unreachable` und `read_only` sind getrennt, weil beide // Fehlschläge sind, aber nur einer etwas über den Token sagt. Ein // unerwarteter Status sagt über die Zonen gar nichts — also sagt er das // jetzt auch, mit der Zahl daneben. if (! $zones->successful()) { return ['ok' => false, 'reason' => 'zone_list_failed', 'status' => $zones->status()]; } // Und der Rumpf muss die Frage auch BEANTWORTEN. // // `successful()` allein genügt nicht: ein Zwischending — ein Portal, ein // Filter, ein Firmenproxy — antwortet gern mit 200 und einer HTML-Seite. // Die hat kein `zones`, `?? []` macht daraus null Zonen, und die Anzeige // behauptet daraufhin, das Hetzner-Konto sei leer. Genau dieser Fall lag // vor: Token mit Lesen und Schreiben, Zone `clupilot.cloud` mit fünfzehn // Einträgen vorhanden — und die Konsole sagte, es gebe keine. // // Nur ein echtes `{"zones": [...]}` zählt als Antwort. Alles andere // heißt: hier hat jemand anderes geredet. Und genau das war die Antwort // des ALTEN Endpunkts, seit die DNS-Konsole in den Nur-Lese-Betrieb // ging: eine 301 auf eine HTML-Seite. $rumpf = $zones->json(); if (! is_array($rumpf) || ! isset($rumpf['zones']) || ! is_array($rumpf['zones'])) { return [ 'ok' => false, 'reason' => 'zone_list_unreadable', 'status' => $zones->status(), 'body' => Str::limit((string) $zones->body(), 120), ]; } $verfuegbar = collect($rumpf['zones'])->pluck('name')->filter()->values(); // Die Liste wird weiterhin geholt, obwohl der Zonenname inzwischen // direkt in den Pfad ginge: sie ist es, die den Fehlschlag unten // beantwortbar macht. `GET /zones/{name}` sagt nur 404 — das ist genau // die Sackgasse, aus der der Betreiber den Token austauscht. if (! $verfuegbar->contains($zone)) { // Die gefundenen Zonen gehören in die Antwort. // // `zone_not_found` allein ist eine Sackgasse: der Betreiber liest // es als „der Token ist falsch" und tauscht ihn aus, obwohl der // Token gerade eben erfolgreich die Zonenliste geholt hat — ein // schlechter Token käme oben als `rejected` heraus. Was fehlt, ist // die ZONE. Nebeneinander gestellt beantwortet sich die Frage von // selbst: gesucht wurde `clupilot.cloud`, im Konto liegt // `clupilot.com` — oder gar nichts, dann gehört die Zone dort // erst angelegt. return [ 'ok' => false, 'reason' => 'zone_not_found', 'zone' => $zone, 'available' => $verfuegbar->all(), ]; } return $this->probeWrite($http, (string) $zone); } /** * The actual point of this whole check. A read-only token gets this far and * looks in the console like a working one; it fails only when a customer VM * needs its A record — after payment. * * @param callable(): PendingRequest $http * @return array */ private function probeWrite(callable $http, string $zone): array { $name = '_clupilot-write-probe-'.Str::lower(Str::random(12)); $rrsetId = $name.'/TXT'; // Guarded the same way as the GET above: this call is just as capable // of hitting a broken network as the first one, and "the network died // mid-write" must stay distinct from "the token cannot write" // (read_only, below) — both are failures, but only one of them says // anything about the token. try { $probe = $http()->post("/zones/{$zone}/rrsets", [ 'name' => $name, 'type' => 'TXT', 'ttl' => 60, // Die Anführungszeichen gehören in den WERT. // // Am Live-Konto gemessen: ohne sie antwortet die API mit 422 // `invalid_input`, „TXT records must be fully escaped with // double quotes". Vor dieser Zeile wäre daraus `read_only` // geworden — die Prüfung hätte einem einwandfreien Token // bescheinigt, er dürfe nicht schreiben, und der Betreiber // hätte einen funktionierenden Schlüssel ausgetauscht. 'records' => [['value' => '"clupilot readiness probe"']], ]); } catch (Throwable) { return ['ok' => false, 'reason' => 'unreachable']; } if (! $probe->successful()) { // Nur 401 und 403 sagen etwas über die BERECHTIGUNG. Ein 422 sagt, // dass der Aufruf falsch geformt war, ein 409 dass schon etwas da // liegt, ein 503 dass Hetzner gerade nicht mag — und keiner davon // ist ein Grund, dem Betreiber zu raten, einen funktionierenden // Token auszutauschen. Dieselbe Trennung wie oben bei der // Zonenliste, eine Stufe weiter. if ($probe->status() === 401 || $probe->status() === 403) { return ['ok' => false, 'reason' => 'read_only', 'status' => $probe->status()]; } return [ 'ok' => false, 'reason' => 'write_failed', 'status' => $probe->status(), 'detail' => Str::limit((string) ($probe->json('error.message') ?? $probe->body()), 160), ]; } // The write already proved the token can write — that is the whole // point of this check, and nothing past this line changes that // verdict. Always attempt to clean up, and guard the attempt itself: // a delete that throws is handled exactly like one that comes back // non-2xx, because both leave the same TXT record sitting in the // zone. Named in the result either way, so someone can remove it by // hand instead of a record nobody knows exists. try { $removed = $http()->delete("/zones/{$zone}/rrsets/{$rrsetId}")->successful(); } catch (Throwable) { $removed = false; } $result = ['ok' => true, 'reason' => 'writable', 'probe_removed' => $removed]; if (! $removed) { // Der Name, den dieser Aufruf gerade nicht wegbekommen hat — nicht // eine ID aus der Antwort, die auch fehlen kann. Was hier steht, // muss man in der Konsole wiederfinden können. $result['leftover_record_id'] = $rrsetId; } return $result; } }