CluPilotCloud/app/Provisioning/Steps/Host/RegisterHostDns.php

96 lines
3.3 KiB
PHP

<?php
namespace App\Provisioning\Steps\Host;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Dns\HetznerDnsClient;
use App\Support\Settings;
use Illuminate\Support\Facades\Cache;
use Throwable;
/**
* Gives the host a name under <zone>: 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();
}
/**
* <datacenter>-<nn>, 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);
$name = sprintf('%s-%02d', $host->datacenter, $next);
Settings::set($key, $next);
$host->update(['dns_name' => $name]); // reserved while still locked
return $name;
});
}
}