136 lines
5.6 KiB
PHP
136 lines
5.6 KiB
PHP
<?php
|
|
|
|
namespace App\Provisioning\Steps\Host;
|
|
|
|
use App\Models\Host;
|
|
use App\Models\ProvisioningRun;
|
|
use App\Provisioning\StepResult;
|
|
use App\Services\Dns\HostDnsDirectory;
|
|
use App\Support\Settings;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Gives the host a name: fsn-01.node.clupilot.com.
|
|
*
|
|
* The entry points at the host's WireGuard address, NOT its public IP, and it
|
|
* is written to the vpn-dns container's --hostsdir — resolvable only inside
|
|
* the tunnel — rather than the public Hetzner zone. The name is for us, so an
|
|
* operator can ssh fsn-01.node.… instead of memorising addresses; publishing
|
|
* it in PUBLIC DNS would hand every scanner the internal subnet and roughly
|
|
* how many hosts sit behind it, which is the one thing the whole network
|
|
* design avoids. See App\Services\Dns\HostDnsDirectory.
|
|
*
|
|
* 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 HostDnsDirectory $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);
|
|
// Die PLATTFORM-Zone, nicht die Kundenzone. Diese Zeile las bis hierher
|
|
// `provisioning.dns.zone` — die Zone der Kundeninstanzen —, und auf
|
|
// dieser Installation hieß ein Host damit `fsn-01.node.clupilot.cloud`.
|
|
// Der Kopfkommentar oben, die Prüfregel in Datacenters und der Test in
|
|
// ServicesTest sagen alle drei `.com`; nur diese Zeile sagte etwas
|
|
// anderes. Die zwei Zonen sind laut OfficialDomains ausdrücklich
|
|
// getrennt — eine Nextcloud ist fremde Software, bei der sich Fremde
|
|
// anmelden, und sie teilt sich deshalb keinen Cookie-Geltungsbereich mit
|
|
// dem Portal. Ein Hostname in dieser Zone hebt die Trennung nicht auf,
|
|
// aber er stellt sie in Frage, und der nächste Griff daneben ist teurer.
|
|
$fqdn = $name.'.node.'.config('provisioning.dns.platform_zone');
|
|
|
|
try {
|
|
$this->dns->write($name, $fqdn, $host->wg_ip);
|
|
} catch (Throwable $e) {
|
|
// Retried first: the write is idempotent (overwrite-by-name), so a
|
|
// retry after a transient failure (e.g. the shared volume briefly
|
|
// unavailable) just rewrites the same entry rather than risking a
|
|
// half-written file.
|
|
$allowed = min(3, (int) $run->max_attempts);
|
|
if ($run->attempt + 1 < $allowed) {
|
|
return StepResult::retry(30, 'DNS unavailable: '.$e->getMessage());
|
|
}
|
|
|
|
// Out of attempts: a name is convenience, and a host that is
|
|
// otherwise ready should not be stranded for the want of one.
|
|
$run->events()->create([
|
|
'step' => $this->key(),
|
|
'outcome' => 'info',
|
|
'message' => 'DNS name could not be registered: '.$e->getMessage(),
|
|
]);
|
|
|
|
return StepResult::advance();
|
|
}
|
|
|
|
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
|
|
{
|
|
// Everything keys off the LABEL, not the raw code: two legacy codes can
|
|
// normalise to the same label (eu_west and eu-west), and separate
|
|
// counters would then hand both of them eu-west-01 — a unique-constraint
|
|
// failure that blocks onboarding rather than a DNS problem we can shrug off.
|
|
$label = $this->dnsLabel($host->datacenter);
|
|
|
|
return Cache::lock('host-dns-name:'.$label, 30)->block(10, function () use ($host, $label) {
|
|
$key = 'dns.sequence.'.$label;
|
|
$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. Matched on the
|
|
// name itself, so it covers every code sharing this label.
|
|
$inUse = Host::query()
|
|
->where('dns_name', 'like', $label.'-%')
|
|
->pluck('dns_name')
|
|
->map(fn (string $name) => (int) substr($name, strrpos($name, '-') + 1))
|
|
->max() ?? 0;
|
|
$next = max($next, $inUse + 1);
|
|
|
|
$name = sprintf('%s-%02d', $label, $next);
|
|
|
|
Settings::set($key, $next);
|
|
$host->update(['dns_name' => $name]); // reserved while still locked
|
|
|
|
return $name;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Datacenter codes created before the rule was tightened can hold characters
|
|
* a DNS label cannot; a name the provider rejects would leave the host
|
|
* nameless forever.
|
|
*/
|
|
private function dnsLabel(string $datacenter): string
|
|
{
|
|
$label = trim(preg_replace('/[^a-z0-9-]+/', '-', strtolower($datacenter)) ?? '', '-');
|
|
|
|
return $label !== '' ? $label : 'node';
|
|
}
|
|
}
|