CluPilotCloud/app/Services/Dns/HttpHetznerDnsClient.php

132 lines
5.7 KiB
PHP

<?php
namespace App\Services\Dns;
use App\Services\Secrets\SecretVault;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Hetzner DNS client, speaking the CLOUD API. Zone from ProvisioningSettings
* (console-stored, falling back to CLUPILOT_DNS_ZONE); the token comes from the
* vault (console-stored, falling back to HETZNER_DNS_TOKEN).
* Not unit-tested against the real service (live I/O).
*
* Hetzner moved DNS into the cloud console on 7 October 2025; the old console
* went read-only on 20 May 2026 and `dns.hetzner.com/api/v1` has redirected to
* the web UI ever since. Old tokens and zone ids were not carried over. Until
* this class was moved across, every provisioning run died in
* ConfigureDnsAndTls — after the customer had paid.
*
* Two things changed beyond the host name:
*
* - Authentication is `Authorization: Bearer`, not `Auth-API-Token`.
* - There are no individual records any more, only RRSets addressed by
* `{name}/{type}` (see RrsetId). A set carries several values and
* `set_records` replaces the whole set, so writing is idempotent at the
* provider. That deletes the old find-then-update walk over `GET /records` —
* and with it the paging bug that once published a SECOND A record for a name
* past the hundredth entry in the zone, round-robining a customer's cloud
* onto a host it was not on. There is no lookup left to get wrong.
*/
class HttpHetznerDnsClient implements HetznerDnsClient
{
/**
* Unchanged from the old client on purpose: this migration moves the calls,
* not the zone's behaviour. Hetzner's floor is 60s.
*/
private const TTL = 300;
private function http(): PendingRequest
{
// Read HERE, at the point of use, rather than in a constructor or
// overlaid onto config at boot — see SecretVault's own docblock (rule
// 3): an overlay would add a query to every request, and a
// long-running queue worker would keep whatever token was true when
// it started.
return Http::withToken((string) app(SecretVault::class)->get('dns.token'))
->acceptJson()
->baseUrl('https://api.hetzner.cloud/v1')
->timeout(15);
}
public function upsertRecord(string $fqdn, string $type, string $value): string
{
// The zone name goes straight into the path now — the old client had to
// look its id up first, and that lookup is simply gone.
$zone = RrsetId::zone();
$name = RrsetId::relativeName($fqdn);
$type = Str::upper($type);
$records = [['value' => $value]];
$created = $this->http()->post("/zones/{$zone}/rrsets", [
'name' => $name,
'type' => $type,
'ttl' => self::TTL,
'records' => $records,
]);
// 409 `uniqueness_error` — measured against the live account — is the
// API saying the set is already there, which on a re-run is the normal
// case rather than a fault. Replacing the whole set from here is what
// makes the second pass land on exactly one value: the address a
// customer's instance moved to, with the one it moved off gone in the
// same call.
//
// Create-first rather than replace-first because it terminates in at
// most two calls with no race window: a 409 proves something exists, and
// set_records then overwrites it whatever it was. The other order
// (replace, 404, create) can lose a race and 409 on the create.
if ($created->status() === 409) {
$this->http()
->post("/zones/{$zone}/rrsets/{$name}/{$type}/actions/set_records", ['records' => $records])
->throw();
} else {
$created->throw();
}
// Composed rather than read back out of the response, because the
// replace branch above and the create branch have to hand back the same
// thing, and because this is the string deleteRecord() takes apart
// again. The composition is the API's own — see RrsetId.
return "{$name}/{$type}";
}
public function deleteRecord(string $recordId): void
{
// An id from the OLD record API cannot address anything here, and
// throwing over it would be worse than the leak it is: PurgeHost calls
// this before deleting the row that holds the id, so a host carrying a
// legacy one could never be purged at all — every attempt would die on
// the same line, for ever, and the machine would sit in the pool.
//
// Said out loud rather than swallowed. The record does stay in the zone,
// and this log line is the only place that can still be noticed.
// See LegacyRecordIds for the ids that CAN be brought across, and for
// why I was wrong to treat one empty database as proof.
if (! RrsetId::isRrsetId($recordId)) {
Log::warning('Skipping a legacy DNS record id — not addressable in the cloud API, remove it by hand in the Hetzner console.', [
'record_id' => $recordId,
'zone' => RrsetId::zone(),
]);
return;
}
[$name, $type] = RrsetId::split($recordId);
$response = $this->http()->delete('/zones/'.RrsetId::zone()."/rrsets/{$name}/{$type}");
// A set that is already gone is the outcome we wanted. Without this, a
// lost response or a retry after a later failure turns every further
// attempt into a 404 — and a host that can then never be purged.
if ($response->notFound()) {
return;
}
$response->throw();
}
}