diff --git a/app/Actions/StartHostOnboarding.php b/app/Actions/StartHostOnboarding.php index 9c9ca0a..8d5e45f 100644 --- a/app/Actions/StartHostOnboarding.php +++ b/app/Actions/StartHostOnboarding.php @@ -5,6 +5,7 @@ namespace App\Actions; use App\Models\Host; use App\Models\ProvisioningRun; use App\Provisioning\Jobs\AdvanceRunJob; +use App\Support\HostName; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; @@ -16,7 +17,7 @@ use Illuminate\Support\Facades\DB; class StartHostOnboarding { /** - * @param array{name: string, datacenter: string, public_ip: string, root_password: string} $input + * @param array{datacenter: string, public_ip: string, root_password: string} $input */ public function run(array $input): Host { @@ -24,7 +25,12 @@ class StartHostOnboarding // leave a permanently pending host with no run. [$host, $run] = DB::transaction(function () use ($input) { $host = Host::create([ - 'name' => $input['name'], + // Den Namen vergibt CluPilot, nicht der Betreiber. Innerhalb + // DIESER Transaktion, weil HostName::claim die + // Rechenzentrums-Zeile sperrt und die Sperre nur bis zu deren + // Ende hält — außerhalb bekämen zwei gleichzeitige + // Anlegen-Vorgänge dieselbe Nummer. + 'name' => HostName::claim($input['datacenter']), 'datacenter' => $input['datacenter'], 'public_ip' => $input['public_ip'], 'status' => 'pending', diff --git a/app/Livewire/Admin/HostCreate.php b/app/Livewire/Admin/HostCreate.php index ddbfaf2..df55aee 100644 --- a/app/Livewire/Admin/HostCreate.php +++ b/app/Livewire/Admin/HostCreate.php @@ -4,6 +4,7 @@ namespace App\Livewire\Admin; use App\Actions\StartHostOnboarding; use App\Support\HostEnrolment; +use App\Support\HostName; use App\Support\HostTakeoverCommand; use Livewire\Attributes\Layout; use Livewire\Attributes\Validate; @@ -27,9 +28,6 @@ class HostCreate extends Component public ?string $createdName = null; - #[Validate('required|string|max:255')] - public string $name = ''; - #[Validate('required|string|exists:datacenters,code,active,1')] public string $datacenter = ''; @@ -61,8 +59,15 @@ class HostCreate extends Component public function render() { + // Ein Name, der ohne Ankündigung entsteht, ist eine Überraschung. Die + // Seite zeigt ihn, sobald ein Rechenzentrum gewählt ist — gelesen, nicht + // reserviert (siehe HostName::preview). + $preview = $this->datacenter === '' ? null : HostName::preview($this->datacenter); + return view('livewire.admin.host-create', [ 'datacenters' => \App\Models\Datacenter::query()->active()->orderBy('name')->get(), + 'previewName' => $preview, + 'previewFqdn' => $preview === null ? null : HostName::fqdn($preview), 'archiveUrl' => HostTakeoverCommand::archiveUrl(), 'missingSettings' => HostTakeoverCommand::missingSettings(), ]); diff --git a/app/Models/Datacenter.php b/app/Models/Datacenter.php index 26b5f33..52dea9c 100644 --- a/app/Models/Datacenter.php +++ b/app/Models/Datacenter.php @@ -12,11 +12,11 @@ class Datacenter extends Model /** @use HasFactory<\Database\Factories\DatacenterFactory> */ use HasFactory, HasUuid; - protected $fillable = ['code', 'name', 'facility', 'location', 'active']; + protected $fillable = ['code', 'name', 'facility', 'location', 'active', 'next_host_number']; protected function casts(): array { - return ['active' => 'boolean']; + return ['active' => 'boolean', 'next_host_number' => 'integer']; } public function scopeActive(Builder $query): Builder diff --git a/app/Models/Host.php b/app/Models/Host.php index 7b2cb83..299962c 100644 --- a/app/Models/Host.php +++ b/app/Models/Host.php @@ -22,7 +22,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', 'dns_name', 'dns_record_id', + 'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', 'dns_record_id', ]; protected $hidden = ['api_token_ref']; diff --git a/app/Provisioning/Jobs/PurgeHost.php b/app/Provisioning/Jobs/PurgeHost.php index af865f4..5724f28 100644 --- a/app/Provisioning/Jobs/PurgeHost.php +++ b/app/Provisioning/Jobs/PurgeHost.php @@ -80,11 +80,12 @@ class PurgeHost implements ShouldQueue app(HetznerDnsClient::class)->deleteRecord($host->dns_record_id); } - // The internal hostsdir entry (vpn-dns) — same reasoning as above, - // for the name every host has actually been getting since. The name - // lives on the row about to be deleted, so it has to come out first. - if (filled($host->dns_name)) { - app(HostDnsDirectory::class)->remove($host->dns_name); + // Der interne hostsdir-Eintrag (vpn-dns). Der Name lebt auf der Zeile, + // die gleich gelöscht wird, also muss er zuerst heraus. `remove()` ist + // auf einen Eintrag, den es nie gab, ausdrücklich ein Nichtstun — ein + // Host, der nie bis zur DNS-Registrierung kam, purgt trotzdem sauber. + if (filled($host->name)) { + app(HostDnsDirectory::class)->remove($host->name); } if (filled($host->wg_pubkey)) { diff --git a/app/Provisioning/Steps/Host/PrepareBaseSystem.php b/app/Provisioning/Steps/Host/PrepareBaseSystem.php index 2dbc6e0..b8dfac0 100644 --- a/app/Provisioning/Steps/Host/PrepareBaseSystem.php +++ b/app/Provisioning/Steps/Host/PrepareBaseSystem.php @@ -5,6 +5,7 @@ namespace App\Provisioning\Steps\Host; use App\Models\ProvisioningRun; use App\Provisioning\StepResult; use App\Services\Ssh\RemoteShell; +use App\Support\HostName; class PrepareBaseSystem extends HostStep { @@ -20,7 +21,12 @@ class PrepareBaseSystem extends HostStep $host = $this->host($run); $this->keyLogin($this->shell, $host); - $fqdn = "{$host->name}.{$host->datacenter}.clupilot.net"; + // Derselbe FQDN, den DNS führt und den die Bootstrap-Zeile mitgibt. + // Hier stand `{$host->name}.{$host->datacenter}.clupilot.net` — eine + // Domain, die im ganzen Repo sonst nirgends vorkommt und in keiner + // Konfiguration steht. Die Maschine trug damit einen dritten Namen im + // eigenen /etc/hosts. + $fqdn = HostName::fqdn($host->name); $hostsLine = "{$host->public_ip} {$fqdn} {$host->name}"; $commands = [ diff --git a/app/Provisioning/Steps/Host/RegisterHostDns.php b/app/Provisioning/Steps/Host/RegisterHostDns.php index bb61403..bd23bf8 100644 --- a/app/Provisioning/Steps/Host/RegisterHostDns.php +++ b/app/Provisioning/Steps/Host/RegisterHostDns.php @@ -2,24 +2,24 @@ 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 App\Support\HostName; 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. + * The name itself is not chosen here — it was assigned once, at creation + * (App\Support\HostName::claim, called from StartHostOnboarding), and this + * step only publishes it. This 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. @@ -41,21 +41,12 @@ class RegisterHostDns extends HostStep 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'); + // Der Name steht seit dem Anlegen fest (StartHostOnboarding → + // HostName::claim). Dieser Schritt bildete ihn früher ein zweites Mal + // — daher kamen zwei Namen für dieselbe Maschine, von denen nur einer + // auflöste. Er veröffentlicht jetzt nur noch, was schon gilt. + $name = $host->name; + $fqdn = HostName::fqdn($name); try { $this->dns->write($name, $fqdn, $host->wg_ip); @@ -82,54 +73,4 @@ class RegisterHostDns extends HostStep return StepResult::advance(); } - - /** - * -, 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'; - } } diff --git a/app/Support/HostName.php b/app/Support/HostName.php new file mode 100644 index 0000000..013f485 --- /dev/null +++ b/app/Support/HostName.php @@ -0,0 +1,114 @@ +-`, fortlaufend je Rechenzentrum und + * niemals wiederverwendet. Maschine, Proxmox-Node, DNS, /etc/hosts und Konsole + * benutzen denselben. + */ +final class HostName +{ + /** + * Der Name, den der nächste Host in diesem Rechenzentrum bekäme. + * + * LIEST den Zähler, verbraucht ihn nicht: die Anlegen-Seite zeigt den Namen, + * bevor gespeichert wird. Zwei gleichzeitig geöffnete Formulare zeigen + * deshalb beide dieselbe Nummer, und der zweite bekommt beim Speichern die + * nächste. Eine Reservierung beim Öffnen wäre die schlechtere Antwort — ein + * abgebrochenes Formular hinterließe eine Lücke im Zähler, die niemand + * wieder auffüllt. + */ + public static function preview(string $datacenterCode): string + { + $from = (int) (Datacenter::query() + ->where('code', $datacenterCode) + ->value('next_host_number') ?? 1); + + return self::free(self::label($datacenterCode), $from)[1]; + } + + /** + * Vergibt den Namen und verbraucht die Nummer. + * + * Gehört in eine Transaktion: die Sperre auf die Rechenzentrums-Zeile hält + * nur bis zu deren Ende, und ohne sie holten sich zwei gleichzeitige + * Anlegen-Vorgänge dieselbe Nummer. + */ + public static function claim(string $datacenterCode): string + { + $dc = Datacenter::query()->where('code', $datacenterCode)->lockForUpdate()->first(); + + if ($dc === null) { + throw new RuntimeException("Kein Rechenzentrum mit dem Code {$datacenterCode}."); + } + + [$number, $name] = self::free(self::label($datacenterCode), (int) $dc->next_host_number); + + $dc->update(['next_host_number' => $number + 1]); + + return $name; + } + + /** + * `fsn-03` → `fsn-03.node.clupilot.com`. + * + * Die PLATTFORM-Zone, nicht die Kundenzone: die zwei sind laut + * OfficialDomains getrennt, und ein Host gehört auf die Seite der Plattform. + */ + public static function fqdn(string $name): string + { + return $name.'.node.'.config('provisioning.dns.platform_zone'); + } + + /** + * Rechenzentrums-Codes von vor der Verschärfung der Prüfregel können + * Zeichen tragen, die eine DNS-Bezeichnung nicht darf. Ein Name, den der + * Anbieter ablehnt, ließe den Host für immer namenlos. + */ + public static function label(string $datacenterCode): string + { + $label = trim(preg_replace('/[^a-z0-9-]+/', '-', strtolower($datacenterCode)) ?? '', '-'); + + return $label !== '' ? $label : 'node'; + } + + /** + * Ab dieser Nummer aufwärts der erste freie Name. + * + * Der Zähler allein reicht nicht: zwei Alt-Codes (`eu_west` und `eu-west`) + * werden zur selben Bezeichnung und führen doch getrennte Zähler. Ohne + * dieses Weiterzählen bekäme der zweite `eu-west-01` und liefe in den + * eindeutigen Index — ein Abbruch beim Anlegen statt eines Namens. + * + * Nach unten geht es dabei nie: eine entfernte `fsn-02` wird nicht wieder + * vergeben, weil der Zähler längst darüber steht. Dieses Weiterzählen ist + * der Riegel, nicht der Zähler. + * + * `%02d` füllt zweistellig auf und wächst ab hundert von selbst weiter. + * + * @return array{0: int, 1: string} + */ + private static function free(string $label, int $from): array + { + $number = max(1, $from); + + while (Host::query()->where('name', sprintf('%s-%02d', $label, $number))->exists()) { + $number++; + } + + return [$number, sprintf('%s-%02d', $label, $number)]; + } +} diff --git a/app/Support/HostTakeoverCommand.php b/app/Support/HostTakeoverCommand.php index 5ffb6d1..74e5648 100644 --- a/app/Support/HostTakeoverCommand.php +++ b/app/Support/HostTakeoverCommand.php @@ -108,9 +108,7 @@ final class HostTakeoverCommand */ public static function fqdnFor(Host $host): string { - $name = $host->dns_name ?: $host->name; - - return $name.'.node.'.config('provisioning.dns.platform_zone'); + return HostName::fqdn($host->name); } /** Die Präfixlänge des Tunnel-Subnetzes, damit `--wg-ip` vollständig ist. */ diff --git a/database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php b/database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php new file mode 100644 index 0000000..1eef863 --- /dev/null +++ b/database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php @@ -0,0 +1,116 @@ +unsignedInteger('next_host_number')->default(1)->after('code'); + }); + + // Der systematische Name wird der Name. Hosts ohne DNS-Namen (noch im + // Onboarding, nie so weit gekommen) behalten ihren — sie tragen keine + // Nummer, die sich übertragen ließe, und das Weiterzählen in + // HostName::free() geht an ihnen vorbei. + DB::table('hosts') + ->whereNotNull('dns_name') + ->where('dns_name', '<>', '') + ->update(['name' => DB::raw('dns_name')]); + + foreach (DB::table('datacenters')->get() as $dc) { + $label = HostName::label($dc->code); + + // Was schon vergeben ist — der Boden, unter den der Zähler nie darf. + $inUse = DB::table('hosts') + ->where('name', 'like', $label.'-%') + ->pluck('name') + ->map(fn (string $name) => (int) substr($name, strrpos($name, '-') + 1)) + ->max() ?? 0; + + // Und was der alte Zähler schon ausgegeben HATTE. Ohne diesen Wert + // ginge die Zusage „niemals wiederverwendet" beim Umzug verloren: + // ein Host, der angelegt und wieder entfernt wurde, steht in keiner + // Zeile mehr, aber sein Name steht noch in den Protokollen. + $carried = (int) (json_decode( + (string) DB::table('app_settings')->where('key', 'dns.sequence.'.$label)->value('value'), + true, + ) ?? 0); + + DB::table('datacenters')->where('id', $dc->id) + ->update(['next_host_number' => max($inUse, $carried) + 1]); + } + + // Der alte Zähler geht mit. Eine tote Einstellung, die noch wie eine + // Quelle aussieht, ist genau das Problem, das diese Migration behebt. + DB::table('app_settings')->where('key', 'like', 'dns.sequence.%')->delete(); + + // Getrennte Aufrufe: Index löschen, Spalte löschen und Index anlegen in + // einem Blueprint bringt SQLite (Testlauf) durcheinander. + Schema::table('hosts', function (Blueprint $table) { + $table->dropUnique(['dns_name']); + }); + + Schema::table('hosts', function (Blueprint $table) { + $table->dropColumn('dns_name'); + }); + + // Ein Riegel, kein Ersatz für den Zähler. + Schema::table('hosts', function (Blueprint $table) { + $table->unique('name'); + }); + } + + public function down(): void + { + Schema::table('hosts', function (Blueprint $table) { + $table->dropUnique(['name']); + }); + + Schema::table('hosts', function (Blueprint $table) { + $table->string('dns_name')->nullable()->unique()->after('name'); + }); + + // Zurück in die zwei Namen: beide tragen ab hier denselben Wert. Die + // getippten Namen von vorher sind fort — sie waren der Fehler. + DB::table('hosts')->update(['dns_name' => DB::raw('name')]); + + foreach (DB::table('datacenters')->get() as $dc) { + DB::table('app_settings')->updateOrInsert( + ['key' => 'dns.sequence.'.HostName::label($dc->code)], + [ + 'value' => json_encode(max(0, (int) $dc->next_host_number - 1)), + 'created_at' => now(), + 'updated_at' => now(), + ], + ); + } + + Schema::table('datacenters', function (Blueprint $table) { + $table->dropColumn('next_host_number'); + }); + } +}; diff --git a/lang/de/hosts.php b/lang/de/hosts.php index 2ffedd8..bc621ae 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -41,7 +41,7 @@ return [ 'field' => [ 'name' => 'Name', - 'name_hint' => 'z. B. pve-fsn-4', + 'name_hint' => 'Vergibt CluPilot fortlaufend. Erreichbar unter :fqdn', 'datacenter' => 'Rechenzentrum', 'public_ip' => 'Öffentliche IP', 'root_password' => 'Root-Passwort (einmalig)', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index 97f1452..c54d004 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -41,7 +41,7 @@ return [ 'field' => [ 'name' => 'Name', - 'name_hint' => 'e.g. pve-fsn-4', + 'name_hint' => 'Assigned by CluPilot in sequence. Reachable at :fqdn', 'datacenter' => 'Datacenter', 'public_ip' => 'Public IP', 'root_password' => 'Root password (one-time)', diff --git a/resources/views/livewire/admin/host-create.blade.php b/resources/views/livewire/admin/host-create.blade.php index 6f75346..937fc58 100644 --- a/resources/views/livewire/admin/host-create.blade.php +++ b/resources/views/livewire/admin/host-create.blade.php @@ -54,12 +54,8 @@

{{ __('hosts.takeover.form_sub') }}

- - - - -