*/ 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']; } $zoneId = collect($zones->json('zones') ?? [])->firstWhere('name', $zone)['id'] ?? null; if ($zoneId === null) { return ['ok' => false, 'reason' => 'zone_not_found', 'zone' => $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. // // 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; } }