132 lines
5.7 KiB
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();
|
|
}
|
|
}
|