*/ 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']; } try { $zones = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15) ->get('https://dns.hetzner.com/api/v1/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. $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(); $zoneId = collect($rumpf['zones'])->firstWhere('name', $zone)['id'] ?? null; if ($zoneId === null) { // 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(), ]; } // 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. // // 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::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15) ->post('https://dns.hetzner.com/api/v1/records', [ 'zone_id' => $zoneId, 'type' => 'TXT', 'name' => '_clupilot-write-probe-'.Str::lower(Str::random(12)), 'value' => 'clupilot readiness probe', 'ttl' => 60, ]); } catch (Throwable) { return ['ok' => false, 'reason' => 'unreachable']; } if (! $probe->successful()) { return ['ok' => false, 'reason' => 'read_only', 'status' => $probe->status()]; } // 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. $recordId = $probe->json('record.id'); try { $removed = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15) ->delete('https://dns.hetzner.com/api/v1/records/'.$recordId) ->successful(); } catch (Throwable) { $removed = false; } $result = ['ok' => true, 'reason' => 'writable', 'probe_removed' => $removed]; if (! $removed) { $result['leftover_record_id'] = $recordId; } return $result; } }