126 lines
4.8 KiB
PHP
126 lines
4.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Dns;
|
|
|
|
use App\Services\Secrets\SecretVault;
|
|
use App\Support\ProvisioningSettings;
|
|
use Illuminate\Http\Client\PendingRequest;
|
|
use Illuminate\Support\Facades\Http;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Hetzner DNS API client. 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).
|
|
*/
|
|
class HttpHetznerDnsClient implements HetznerDnsClient
|
|
{
|
|
/** Hetzner's own maximum; asking for more is answered with 100 anyway. */
|
|
private const PAGE_SIZE = 100;
|
|
|
|
/**
|
|
* A ceiling on the record walk — 10 000 records in one zone is far past
|
|
* anything this product will hold, and far short of a request loop that never
|
|
* ends because a page count came back wrong.
|
|
*/
|
|
private const MAX_PAGES = 100;
|
|
|
|
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::withHeaders(['Auth-API-Token' => (string) app(SecretVault::class)->get('dns.token')])
|
|
->baseUrl('https://dns.hetzner.com/api/v1')
|
|
->timeout(15);
|
|
}
|
|
|
|
private function zoneId(): string
|
|
{
|
|
$zone = ProvisioningSettings::dnsZone();
|
|
$zones = $this->http()->get('/zones', ['name' => $zone])->throw()->json('zones', []);
|
|
|
|
return $zones[0]['id'] ?? throw new RuntimeException("DNS zone {$zone} not found");
|
|
}
|
|
|
|
public function upsertRecord(string $fqdn, string $type, string $value): string
|
|
{
|
|
$zoneId = $this->zoneId();
|
|
$name = rtrim(str_replace('.'.ProvisioningSettings::dnsZone(), '', $fqdn), '.');
|
|
|
|
$existing = $this->findRecord($zoneId, $name, $type);
|
|
|
|
if ($existing) {
|
|
$this->http()->put("/records/{$existing['id']}", [
|
|
'zone_id' => $zoneId, 'type' => $type, 'name' => $name, 'value' => $value, 'ttl' => 300,
|
|
])->throw();
|
|
|
|
return (string) $existing['id'];
|
|
}
|
|
|
|
return (string) $this->http()->post('/records', [
|
|
'zone_id' => $zoneId, 'type' => $type, 'name' => $name, 'value' => $value, 'ttl' => 300,
|
|
])->throw()->json('record.id');
|
|
}
|
|
|
|
/**
|
|
* The record with this name and type in the zone, or null.
|
|
*
|
|
* Paged, because Hetzner pages. `GET /records` answers with at most 100
|
|
* entries and this asked only for the first page — so once the zone held more
|
|
* than a hundred records the lookup stopped finding entries that were plainly
|
|
* there, fell through to POST, and Hetzner accepted a SECOND A record for the
|
|
* same name. Two addresses for one instance, resolved round-robin, one of them
|
|
* a host the customer's machine is not on: the cloud was up about half the
|
|
* time, and every subsequent run made it worse, because the `address` and
|
|
* `plan-change` pipelines upsert on every pass.
|
|
*
|
|
* A hundred is not a distant number here. The zone carries one A record per
|
|
* customer instance plus one per host, and nothing prunes it faster than
|
|
* instances are sold.
|
|
*
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function findRecord(string $zoneId, string $name, string $type): ?array
|
|
{
|
|
$page = 1;
|
|
|
|
do {
|
|
$response = $this->http()
|
|
->get('/records', ['zone_id' => $zoneId, 'page' => $page, 'per_page' => self::PAGE_SIZE])
|
|
->throw();
|
|
|
|
foreach ($response->json('records', []) as $record) {
|
|
if (($record['name'] ?? null) === $name && ($record['type'] ?? null) === $type) {
|
|
return $record;
|
|
}
|
|
}
|
|
|
|
// Absent or nonsensical pagination means "this was the lot": a client
|
|
// that trusted the field to be there could loop for ever against an
|
|
// API having a bad day, and MAX_PAGES is the floor under that even
|
|
// when it answers.
|
|
$lastPage = (int) $response->json('meta.pagination.last_page', $page);
|
|
} while ($page++ < min($lastPage, self::MAX_PAGES));
|
|
|
|
return null;
|
|
}
|
|
|
|
public function deleteRecord(string $recordId): void
|
|
{
|
|
$response = $this->http()->delete("/records/{$recordId}");
|
|
|
|
// A record 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();
|
|
}
|
|
}
|