*/ 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. $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, ]); if (! $probe->successful()) { return ['ok' => false, 'reason' => 'read_only', 'status' => $probe->status()]; } // Always clean up, even when the delete itself fails: a leftover TXT // record with this name is harmless, but has to be named in the // result so someone can remove it by hand. $recordId = $probe->json('record.id'); $removed = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15) ->delete('https://dns.hetzner.com/api/v1/records/'.$recordId) ->successful(); return ['ok' => true, 'reason' => 'writable', 'probe_removed' => $removed]; } }