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
parent
7941935f57
commit
5db2f7fda7
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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++;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ return [
|
|||
Host\ConfigureProxmox::class,
|
||||
Host\CreateAutomationToken::class,
|
||||
Host\VerifyProxmoxApi::class,
|
||||
Host\RegisterHostDns::class,
|
||||
Host\RegisterCapacity::class,
|
||||
Host\CompleteHostOnboarding::class,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue