: fsn-01.node.clupilot.com. * * The record points at the host's WireGuard address, NOT its public IP. The * name is for us — so an operator can ssh fsn-01.node.… instead of memorising * addresses — and publishing a Proxmox host's public address would hand every * scanner a target, which is the one thing the whole network design avoids. * * DNS is convenience, not a prerequisite for a working host: a failure here * logs and moves on rather than stranding an otherwise finished onboarding. */ class RegisterHostDns extends HostStep { public function __construct(private HetznerDnsClient $dns) {} public function key(): string { return 'register_host_dns'; } public function execute(ProvisioningRun $run): StepResult { $host = $this->host($run); if (blank($host->wg_ip)) { return StepResult::fail('The host has no management address to publish.'); } // Reserved and persisted before anything is published: releasing the // lock with only a candidate in hand let two concurrent onboardings pick // the same name and overwrite each other's record. $name = $host->dns_name ?: $this->reserveName($host); $fqdn = $name.'.node.'.config('provisioning.dns.zone', 'clupilot.com'); try { $recordId = $this->dns->upsertRecord($fqdn, 'A', $host->wg_ip); } catch (Throwable $e) { $run->events()->create([ 'step' => $this->key(), 'outcome' => 'info', 'message' => 'DNS name could not be registered: '.$e->getMessage(), ]); return StepResult::advance(); } $host->update(['dns_record_id' => $recordId]); return StepResult::advance(); } /** * -, numbered per datacenter and never reused. * * The counter is stored rather than derived from the hosts that happen to * exist: deriving it hands the highest number straight back out after that * host is removed, so a cached name would resolve to a different machine. */ private function reserveName(Host $host): string { return Cache::lock('host-dns-name:'.$host->datacenter, 30)->block(10, function () use ($host) { $key = 'dns.sequence.'.$host->datacenter; $next = ((int) Settings::get($key, 0)) + 1; // Never below what is already in use — a settings store that was // wiped must not start handing out live names again. $inUse = Host::query() ->where('datacenter', $host->datacenter) ->whereNotNull('dns_name') ->pluck('dns_name') ->map(fn (string $name) => (int) substr($name, strrpos($name, '-') + 1)) ->max() ?? 0; $next = max($next, $inUse + 1); // Datacenter codes created before the rule was tightened can still // hold characters a DNS label cannot; a name that the provider // rejects would leave the host nameless forever. $label = trim(preg_replace('/[^a-z0-9-]+/', '-', strtolower($host->datacenter)) ?? '', '-'); $name = sprintf('%s-%02d', $label !== '' ? $label : 'node', $next); Settings::set($key, $next); $host->update(['dns_name' => $name]); // reserved while still locked return $name; }); } }