CluPilotCloud/app/Services/Dns/DnsTokenCheck.php

108 lines
4.3 KiB
PHP

<?php
namespace App\Services\Dns;
use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Throwable;
/**
* Whether the configured Hetzner DNS token can actually WRITE to the zone,
* not merely read it.
*
* Listing the zone proves nothing: a read-only token lists it just as
* cleanly as a read-write one, and looks identical in the console — it only
* fails once a customer VM needs its A record, after the customer has
* already paid. So this writes a throwaway TXT record and deletes it again
* immediately; the write (and its rejection) is the only thing that actually
* tells the two kinds of token apart.
*
* Deliberately does its own HTTP instead of going through HttpHetznerDnsClient:
* that client always reads the STORED token from the vault, and this check
* has to be able to test a candidate value before it is ever saved — exactly
* the same reason StripeCheck does not go through HttpStripeClient.
*/
final class DnsTokenCheck
{
/** @return array<string, mixed> */
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;
}
}