107 lines
4.0 KiB
PHP
107 lines
4.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Dns;
|
|
|
|
use App\Support\ProvisioningSettings;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* How an fqdn becomes an address in the Hetzner Cloud DNS API, in one place.
|
|
*
|
|
* There are no individual records any more — an RRSet is addressed by
|
|
* `{name}/{type}`, and that pair IS its id (cloud.spec.json: "The ID is
|
|
* composed of the name and type, joined with a slash"). Everything this app
|
|
* stores in `hosts.dns_record_id`, `dns_records.record_id` and the
|
|
* `dns_record_id` run resource is such a pair.
|
|
*
|
|
* Shared by HttpHetznerDnsClient and FakeHetznerDnsClient on purpose. A fake
|
|
* that hands back `rec-1` where the real client hands back `berger/A` lets
|
|
* every test that purges a host go green over a teardown that would throw in
|
|
* production — the fake has to speak the same dialect or it proves nothing.
|
|
*/
|
|
final class RrsetId
|
|
{
|
|
/** The configured zone, lower case and without a trailing dot. */
|
|
public static function zone(): string
|
|
{
|
|
$zone = Str::lower(rtrim(trim((string) ProvisioningSettings::dnsZone()), '.'));
|
|
|
|
return $zone !== '' ? $zone : throw new RuntimeException('No DNS zone configured');
|
|
}
|
|
|
|
/** `{name}/{TYPE}` for an fqdn inside the configured zone. */
|
|
public static function for(string $fqdn, string $type): string
|
|
{
|
|
return self::relativeName($fqdn).'/'.Str::upper($type);
|
|
}
|
|
|
|
/**
|
|
* The zone-relative name Hetzner wants: lower case, no trailing dot, and
|
|
* NOT ending in the zone name.
|
|
*
|
|
* The last part is a trap with no floor under it. Measured against the live
|
|
* account on 31 July 2026: a POST carrying `berger.clupilot.cloud` as the
|
|
* name of a set in zone `clupilot.cloud` comes back 201 — the API does not
|
|
* reject it, it simply creates `berger.clupilot.cloud.clupilot.cloud`.
|
|
* Nothing downstream notices either; the run goes green and the customer's
|
|
* cloud has an address that resolves nowhere.
|
|
*
|
|
* Same reason an fqdn from outside the zone is refused rather than sent: it
|
|
* would be accepted just as quietly, under a name nobody looks for.
|
|
*/
|
|
public static function relativeName(string $fqdn): string
|
|
{
|
|
$zone = self::zone();
|
|
$name = rtrim(Str::lower(trim($fqdn)), '.');
|
|
|
|
if ($name === $zone) {
|
|
return '@';
|
|
}
|
|
|
|
if (! str_ends_with($name, '.'.$zone)) {
|
|
throw new RuntimeException("{$fqdn} does not sit in DNS zone {$zone}");
|
|
}
|
|
|
|
return substr($name, 0, -strlen('.'.$zone));
|
|
}
|
|
|
|
/** Is this the cloud API's `{name}/{type}`, or an id from the old record API? */
|
|
public static function isRrsetId(string $id): bool
|
|
{
|
|
$parts = explode('/', trim($id));
|
|
|
|
return count($parts) === 2 && $parts[0] !== '' && $parts[1] !== '';
|
|
}
|
|
|
|
/**
|
|
* `{name}/{type}` back into its two halves.
|
|
*
|
|
* An id from the old record API has no slash in it and cannot address
|
|
* anything here: passing one in would build `/rrsets/rec-123/` with an
|
|
* empty type — a call that goes somewhere else and can still come back
|
|
* looking like a success.
|
|
*
|
|
* This used to claim no such id existed anywhere, on the strength of three
|
|
* tables being empty on the DEVELOPMENT database. That proved nothing about
|
|
* any other installation, and the live server is one (Codex review
|
|
* 2026-07-31, P1). Callers therefore ask isRrsetId() first and treat a
|
|
* legacy id as a leak to report, not as a reason to die — see
|
|
* HttpHetznerDnsClient::deleteRecord() and LegacyRecordIds.
|
|
*
|
|
* @return array{0: string, 1: string}
|
|
*/
|
|
public static function split(string $id): array
|
|
{
|
|
$parts = explode('/', trim($id));
|
|
|
|
if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') {
|
|
throw new RuntimeException(
|
|
"Not a Hetzner RRSet id: {$id} — expected {name}/{type}, e.g. berger/A"
|
|
);
|
|
}
|
|
|
|
return [$parts[0], Str::upper($parts[1])];
|
|
}
|
|
}
|