164 lines
7.1 KiB
PHP
164 lines
7.1 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'];
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|