fix(datacenters): a code becomes a DNS label, so validate it as one

alpha_dash accepted eu_west, -edge and edge-, none of which are valid DNS
labels — every host in such a datacenter would have failed registration and,
because DNS is non-fatal, silently ended up without a name. The console now
requires a proper label, and the step normalises anything created before that
rule so existing rows still get a usable name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 23:56:50 +02:00
parent 69043f9ffc
commit e96f4c1c1a
3 changed files with 27 additions and 3 deletions

View File

@ -11,7 +11,7 @@ use Livewire\Component;
#[Layout('layouts.admin')]
class Datacenters extends Component
{
#[Validate('required|string|max:8|alpha_dash|unique:datacenters,code')]
#[Validate(['required', 'string', 'max:8', 'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/', 'unique:datacenters,code'])]
public string $code = '';
#[Validate('required|string|max:255')]
@ -31,7 +31,10 @@ class Datacenters extends Component
// Country must be one of the curated list — a forged request can bypass
// the <select>, so enforce membership server-side (mirrors EditDatacenter).
$data = $this->validate([
'code' => 'required|string|max:8|alpha_dash|unique:datacenters,code',
// A datacenter code becomes a DNS label (fsn-01.node.clupilot.com),
// so it has to be one: lowercase, no underscores, no leading or
// trailing dash.
'code' => ['required', 'string', 'max:8', 'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/', 'unique:datacenters,code'],
'name' => 'required|string|max:255',
'location' => ['nullable', Rule::in(array_keys((array) config('countries')))],
]);

View File

@ -84,7 +84,11 @@ class RegisterHostDns extends HostStep
->max() ?? 0;
$next = max($next, $inUse + 1);
$name = sprintf('%s-%02d', $host->datacenter, $next);
// Datacenter codes created before the rule was tightened can still
// hold characters a DNS label cannot; a name that the provider
// rejects would leave the host nameless forever.
$label = trim(preg_replace('/[^a-z0-9-]+/', '-', strtolower($host->datacenter)) ?? '', '-');
$name = sprintf('%s-%02d', $label !== '' ? $label : 'node', $next);
Settings::set($key, $next);
$host->update(['dns_name' => $name]); // reserved while still locked

View File

@ -344,3 +344,20 @@ it('keeps the host until its DNS record is really gone', function () {
expect(App\Models\Host::query()->whereKey($host->id)->exists())->toBeFalse()
->and($s['dns']->records)->toBe([]);
});
it('builds a valid DNS label even from an awkward datacenter code', function () {
$s = fakeServices();
// Inserted directly: the console now rejects this shape, but rows created
// before the rule was tightened still exist.
App\Models\Datacenter::factory()->create(['code' => 'eu_west', 'name' => 'EU West']);
$host = App\Models\Host::factory()->active()->create([
'datacenter' => 'eu_west', 'wg_ip' => '10.66.0.70', 'dns_name' => null,
]);
$run = App\Models\ProvisioningRun::factory()->forHost($host)->create(['pipeline' => 'host']);
(new App\Provisioning\Steps\Host\RegisterHostDns($s['dns']))->execute($run);
$name = $host->fresh()->dns_name;
expect($name)->toBe('eu-west-01')
->and($name)->toMatch('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/');
});