feat(hosts): give each host a DNS name under the public zone

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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 23:49:18 +02:00
parent 7941935f57
commit 5db2f7fda7
6 changed files with 175 additions and 1 deletions

View File

@ -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'];

View File

@ -0,0 +1,80 @@
<?php
namespace App\Provisioning\Steps\Host;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Dns\HetznerDnsClient;
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.');
}
$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();
}
/**
* <datacenter>-<nn>, 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);
});
}
}

View File

@ -9,6 +9,11 @@ class FakeHetznerDnsClient implements HetznerDnsClient
/** @var array<string, string> fqdn => record_id */
public array $records = [];
/** @var array<string, string> 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++;
}

View File

@ -39,6 +39,7 @@ return [
Host\ConfigureProxmox::class,
Host\CreateAutomationToken::class,
Host\VerifyProxmoxApi::class,
Host\RegisterHostDns::class,
Host\RegisterCapacity::class,
Host\CompleteHostOnboarding::class,
],

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The host's own name in DNS, e.g. fsn-01 fsn-01.node.clupilot.com.
*
* Stored rather than derived, because a derived sequence would shift the moment
* a host is removed and silently rename its neighbours.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('hosts', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -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();
});