From 5db2f7fda71429aad1b25833ceef38b223e75028 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 23:49:18 +0200 Subject: [PATCH] feat(hosts): give each host a DNS name under the public zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fsn-01.node.clupilot.com, numbered per datacenter and never reused — removing a host must not renumber its neighbours onto its name, so the number is stored rather than derived. The record points at the host's WireGuard address, not its public IP: the name exists so an operator can reach a host by name over the VPN, and publishing a Proxmox host's public address would hand every scanner a target, which is the one thing this network design avoids. DNS is convenience, not a prerequisite: a failure logs an event and lets the onboarding finish rather than stranding a host that is otherwise ready. Co-Authored-By: Claude Opus 4.8 --- app/Models/Host.php | 2 +- .../Steps/Host/RegisterHostDns.php | 80 +++++++++++++++++++ app/Services/Dns/FakeHetznerDnsClient.php | 7 ++ config/provisioning.php | 1 + ..._25_240000_add_dns_name_to_hosts_table.php | 30 +++++++ tests/Feature/Provisioning/HostStepsTest.php | 56 +++++++++++++ 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 app/Provisioning/Steps/Host/RegisterHostDns.php create mode 100644 database/migrations/2026_07_25_240000_add_dns_name_to_hosts_table.php diff --git a/app/Models/Host.php b/app/Models/Host.php index b6019df..5699b2d 100644 --- a/app/Models/Host.php +++ b/app/Models/Host.php @@ -18,7 +18,7 @@ class Host extends Model implements ProvisioningSubject protected $fillable = [ 'name', 'datacenter', 'cluster', 'public_ip', 'wg_ip', 'wg_pubkey', 'ssh_host_key', 'api_token_ref', 'total_gb', 'total_ram_mb', 'cpu_cores', - 'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', + 'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', 'dns_name', 'dns_record_id', ]; protected $hidden = ['api_token_ref']; diff --git a/app/Provisioning/Steps/Host/RegisterHostDns.php b/app/Provisioning/Steps/Host/RegisterHostDns.php new file mode 100644 index 0000000..ca8634d --- /dev/null +++ b/app/Provisioning/Steps/Host/RegisterHostDns.php @@ -0,0 +1,80 @@ +: 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.'); + } + + $name = $host->dns_name ?: $this->allocateName($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_name' => $name, 'dns_record_id' => $recordId]); + + return StepResult::advance(); + } + + /** + * -, numbered per datacenter and never reused: the number + * is one past the highest ever issued there, so removing a host does not + * renumber the others. + */ + private function allocateName(Host $host): string + { + return Cache::lock('host-dns-name:'.$host->datacenter, 15)->block(10, function () use ($host) { + $taken = Host::query() + ->where('datacenter', $host->datacenter) + ->whereNotNull('dns_name') + ->pluck('dns_name'); + + $highest = $taken + ->map(fn (string $name) => (int) substr($name, strrpos($name, '-') + 1)) + ->max() ?? 0; + + return sprintf('%s-%02d', $host->datacenter, $highest + 1); + }); + } +} diff --git a/app/Services/Dns/FakeHetznerDnsClient.php b/app/Services/Dns/FakeHetznerDnsClient.php index f224f60..ac482d3 100644 --- a/app/Services/Dns/FakeHetznerDnsClient.php +++ b/app/Services/Dns/FakeHetznerDnsClient.php @@ -9,6 +9,11 @@ class FakeHetznerDnsClient implements HetznerDnsClient /** @var array fqdn => record_id */ public array $records = []; + /** @var array fqdn => published value — without this a test + * cannot tell which address ended up in DNS, which is the whole point for + * host records. */ + public array $values = []; + public bool $failUpsert = false; private int $counter = 1; @@ -19,6 +24,8 @@ class FakeHetznerDnsClient implements HetznerDnsClient throw new RuntimeException('hetzner dns 5xx'); } + $this->values[$fqdn] = $value; + return $this->records[$fqdn] ??= 'rec-'.$this->counter++; } diff --git a/config/provisioning.php b/config/provisioning.php index 39c0610..220c381 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -39,6 +39,7 @@ return [ Host\ConfigureProxmox::class, Host\CreateAutomationToken::class, Host\VerifyProxmoxApi::class, + Host\RegisterHostDns::class, Host\RegisterCapacity::class, Host\CompleteHostOnboarding::class, ], diff --git a/database/migrations/2026_07_25_240000_add_dns_name_to_hosts_table.php b/database/migrations/2026_07_25_240000_add_dns_name_to_hosts_table.php new file mode 100644 index 0000000..bfef23e --- /dev/null +++ b/database/migrations/2026_07_25_240000_add_dns_name_to_hosts_table.php @@ -0,0 +1,30 @@ +string('dns_name')->nullable()->unique()->after('name'); + $table->string('dns_record_id')->nullable()->after('dns_name'); + }); + } + + public function down(): void + { + Schema::table('hosts', function (Blueprint $table) { + $table->dropUnique(['dns_name']); + $table->dropColumn(['dns_name', 'dns_record_id']); + }); + } +}; diff --git a/tests/Feature/Provisioning/HostStepsTest.php b/tests/Feature/Provisioning/HostStepsTest.php index 3dcd069..f24eb2b 100644 --- a/tests/Feature/Provisioning/HostStepsTest.php +++ b/tests/Feature/Provisioning/HostStepsTest.php @@ -239,3 +239,59 @@ it('marks the host active on completion', function () { expect(app(CompleteHostOnboarding::class)->execute(hostRun($host))->type)->toBe('advance') ->and($host->fresh()->status)->toBe('active'); }); + +it('gives the host a name that points at the tunnel, not at its public address', function () { + $s = fakeServices(); + $host = App\Models\Host::factory()->active()->create([ + 'datacenter' => 'fsn', + 'wg_ip' => '10.66.0.11', + 'public_ip' => '203.0.113.11', + 'dns_name' => null, + ]); + $run = App\Models\ProvisioningRun::factory()->forHost($host)->create(["pipeline" => "host"]); + + $result = (new App\Provisioning\Steps\Host\RegisterHostDns($s['dns']))->execute($run); + + expect($result->type)->toBe("advance") + ->and($host->fresh()->dns_name)->toBe('fsn-01'); + + // The management address: publishing a Proxmox host's public IP would hand + // every scanner a target. + expect($s['dns']->values['fsn-01.node.clupilot.com'])->toBe('10.66.0.11') + ->and($s['dns']->values['fsn-01.node.clupilot.com'])->not->toBe('203.0.113.11'); +}); + +it('numbers hosts per datacenter and never reuses a number', function () { + $s = fakeServices(); + $names = []; + + foreach (['fsn', 'fsn', 'hel'] as $i => $dc) { + $host = App\Models\Host::factory()->active()->create([ + 'datacenter' => $dc, 'wg_ip' => '10.66.0.'.(20 + $i), 'dns_name' => null, + ]); + $run = App\Models\ProvisioningRun::factory()->forHost($host)->create(["pipeline" => "host"]); + (new App\Provisioning\Steps\Host\RegisterHostDns($s['dns']))->execute($run); + $names[] = $host->fresh()->dns_name; + } + + expect($names)->toBe(['fsn-01', 'fsn-02', 'hel-01']); + + // Removing fsn-01 must not renumber fsn-02 onto its name. + App\Models\Host::query()->where('dns_name', 'fsn-01')->delete(); + $host = App\Models\Host::factory()->active()->create(['datacenter' => 'fsn', 'wg_ip' => '10.66.0.30', 'dns_name' => null]); + $run = App\Models\ProvisioningRun::factory()->forHost($host)->create(["pipeline" => "host"]); + (new App\Provisioning\Steps\Host\RegisterHostDns($s['dns']))->execute($run); + + expect($host->fresh()->dns_name)->toBe('fsn-03'); +}); + +it('does not strand an onboarding because DNS was unreachable', function () { + $s = fakeServices(); + $s['dns']->failUpsert = true; + $host = App\Models\Host::factory()->active()->create(['datacenter' => 'fsn', 'wg_ip' => '10.66.0.40', 'dns_name' => null]); + $run = App\Models\ProvisioningRun::factory()->forHost($host)->create(["pipeline" => "host"]); + + // A name is convenience; the host works without it. + expect((new App\Provisioning\Steps\Host\RegisterHostDns($s['dns']))->execute($run)->type)->toBe("advance"); + expect($run->events()->where('step', 'register_host_dns')->exists())->toBeTrue(); +});