From ac81bc5b98ffd17bc03a31e15143531fe2f8bc51 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 01:00:53 +0200 Subject: [PATCH] =?UTF-8?q?Release=20v1.3.82=20=E2=80=94=20=C3=9Cbernahme-?= =?UTF-8?q?Befehlszeile=20neu=20ausstellen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Zeile gab es genau einmal: auf der Seite direkt nach dem Anlegen. Wer sie dort nicht kopierte — oder wem sie wegbrach, wie beim WireGuard-Fehler bis 1.3.80 — hatte danach einen Host in der Liste, für den es keinen Weg zu einer Befehlszeile mehr gab. Ein zweites Anlegen scheitert an der eindeutigen IP, also blieb nur Entfernen und von vorn. HostTakeoverCommand behauptet in seinem Kopfkommentar, die Zeile werde "an zwei Stellen gezeigt (beim Anlegen und beim Neuausstellen eines Codes)". Das Zweite gab es nie — ein Kommentar, der eine Absicht beschreibt und wie eine Beschreibung des Gebauten klingt. Neu: ReissueTakeover als Modal auf der Host-Detailseite. Bestätigen zuerst (R23), denn ein bisher ausgegebener Code wird dabei wertlos; danach steht die ganze Zeile mit Kopieren-Knopf darin, nicht nur der Code. Codex, zwei Runden, beide Male dieselbe Sorte Fehler von mir — die Ansicht versteckt den Knopf, die Methode prüft nichts: - P1: `issue()` erzwingt jetzt selbst, welcher Zustand zulässig ist. Ein Modal, das offen blieb, während der Host aktiv wurde, rief die Methode trotzdem; eine Livewire-Methode ist ohnehin von aussen aufrufbar. - P2: `issue()` prüft die Tunnel-Einstellungen erneut. Sonst wird ein gültiger Code entwertet und dafür eine Zeile ausgegeben, der die WireGuard-Angaben fehlen — genau die kaputte Zeile, vor der die Warnung daneben steht. - P1 der zweiten Runde: `disabled` gehörte nicht in die Liste der zulässigen Zustände. Es sieht aus wie "noch nicht fertig" und ist das Gegenteil — toggleMaintenance() schaltet einen LAUFENDEN Host so still. Der Knopf hätte eine Produktionsmaschine zur Neuinstallation angeboten. Die Bedingung steht deshalb einmal am Bauteil (ReissueTakeover::eligible) und wird von Ansicht und Methode gefragt: zwei Fassungen liefen auseinander, sobald jemand eine ändert. 2231 Tests grün. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- app/Livewire/Admin/ReissueTakeover.php | 114 ++++++++++++++++ lang/de/hosts.php | 10 ++ lang/en/hosts.php | 10 ++ .../livewire/admin/host-detail.blade.php | 8 ++ .../livewire/admin/reissue-takeover.blade.php | 69 ++++++++++ tests/Feature/Admin/HostManagementTest.php | 126 +++++++++++++++++- 7 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 app/Livewire/Admin/ReissueTakeover.php create mode 100644 resources/views/livewire/admin/reissue-takeover.blade.php diff --git a/VERSION b/VERSION index 258a5b8..ad60f23 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.81 +1.3.82 diff --git a/app/Livewire/Admin/ReissueTakeover.php b/app/Livewire/Admin/ReissueTakeover.php new file mode 100644 index 0000000..9162a5d --- /dev/null +++ b/app/Livewire/Admin/ReissueTakeover.php @@ -0,0 +1,114 @@ +status, self::ELIGIBLE, true); + } + + public function mount(string $uuid): void + { + $this->uuid = $uuid; + $this->name = Host::query()->where('uuid', $uuid)->value('name') ?? ''; + } + + /** + * Ein Modal ist ohne die Route-Middleware der Seite erreichbar (R20) und + * prüft die Berechtigung deshalb selbst — hier besonders, weil in der + * Antwort ein privater WireGuard-Schlüssel steht. + */ + public function issue(): void + { + $this->authorize('hosts.manage'); + + $host = Host::query()->where('uuid', $this->uuid)->firstOrFail(); + + // Der Knopf steht nur bei passendem Zustand — aber ein Modal, das offen + // blieb, während der Host aktiv wurde, ruft diese Methode trotzdem, und + // eine Livewire-Methode ist ohnehin von außen aufrufbar. Die + // Sichtbarkeit eines Knopfes ist keine Prüfung (Codex P1): sonst + // tauschte das hier den Schlüssel einer laufenden Maschine und schnitte + // sie aus dem Tunnel. + if (! self::eligible($host)) { + $this->refusal = __('hosts.reissue.not_eligible'); + + return; + } + + // Dieselbe Prüfung wie in der Ansicht, aus demselben Grund (Codex P2). + // Sonst wird ein gültiger Code entwertet und dafür eine Zeile + // ausgegeben, der die Tunnel-Angaben fehlen — genau die kaputte Zeile, + // vor der die Warnung daneben steht. + if (HostTakeoverCommand::missingSettings() !== []) { + $this->refusal = __('hosts.takeover.missing_title'); + + return; + } + + $this->refusal = null; + $this->command = HostTakeoverCommand::for($host, HostEnrolment::issueWithKeys($host)); + } + + public function render() + { + return view('livewire.admin.reissue-takeover', [ + 'missingSettings' => HostTakeoverCommand::missingSettings(), + ]); + } +} diff --git a/lang/de/hosts.php b/lang/de/hosts.php index 0347e97..d4a810c 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -134,6 +134,16 @@ return [ 'secure_host_firewall' => 'Host-Firewall absichern', 'complete_host_onboarding' => 'Onboarding abschließen', ], + 'reissue' => [ + 'action' => 'Befehlszeile neu ausstellen', + 'title' => 'Neue Befehlszeile ausstellen?', + 'body' => 'Für :name entsteht ein neuer Einmal-Code und ein neues Schlüsselpaar. Die Zeile wird danach einmal angezeigt.', + 'invalidates' => 'Ein bisher ausgegebener Code wird damit wertlos. Läuft auf der Maschine gerade eine Übernahme, verliert sie mitten im Lauf ihren Ausweis — dann wird sie neu aufgesetzt, nicht nachgebessert.', + 'confirm' => 'Neu ausstellen', + 'done' => 'Fertig', + 'not_eligible' => 'Dieser Host läuft bereits. Eine neue Befehlszeile würde die Maschine neu aufsetzen — dafür wird der Host entfernt und neu angelegt.', + ], + 'takeover' => [ 'eyebrow' => 'Host-Übernahme', 'how_title' => 'So läuft eine Host-Übernahme ab', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index 66eedb9..bef3870 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -134,6 +134,16 @@ return [ 'secure_host_firewall' => 'Secure host firewall', 'complete_host_onboarding' => 'Complete onboarding', ], + 'reissue' => [ + 'action' => 'Re-issue the command line', + 'title' => 'Issue a new command line?', + 'body' => 'A new one-time code and a new key pair are created for :name. The line is then shown once.', + 'invalidates' => 'Any code handed out so far becomes worthless. If a takeover is running on the machine right now, it loses its credentials mid-run — and then it is reinstalled rather than repaired.', + 'confirm' => 'Re-issue', + 'done' => 'Done', + 'not_eligible' => 'This host is already running. A new command line would reinstall the machine — for that, remove the host and create it again.', + ], + 'takeover' => [ 'eyebrow' => 'Host takeover', 'how_title' => 'How a host takeover works', diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php index 31a0d96..f393627 100644 --- a/resources/views/livewire/admin/host-detail.blade.php +++ b/resources/views/livewire/admin/host-detail.blade.php @@ -26,6 +26,14 @@ {{ $host->status === 'active' ? __('hosts.detail.drain') : __('hosts.detail.activate') }} @endif + {{-- Die Bedingung steht am Bauteil, nicht hier: `issue()` prüft + dieselbe, und zwei Fassungen liefen auseinander. --}} + @if (\App\Livewire\Admin\ReissueTakeover::eligible($host)) + + {{ __('hosts.reissue.action') }} + + @endif {{ __('hosts.remove') }} diff --git a/resources/views/livewire/admin/reissue-takeover.blade.php b/resources/views/livewire/admin/reissue-takeover.blade.php new file mode 100644 index 0000000..74cfbc0 --- /dev/null +++ b/resources/views/livewire/admin/reissue-takeover.blade.php @@ -0,0 +1,69 @@ +{{-- R24: Kopf und Fuß stehen fest, gescrollt wird nur die Mitte. Die + Befehlszeile ist lang, und sie darf ihren eigenen Kopieren-Knopf nicht aus + dem Fenster schieben. --}} + + +

+ {{ $command === null ? __('hosts.reissue.title') : __('hosts.takeover.command_title') }} +

+

+ {{ $command === null ? __('hosts.reissue.body', ['name' => $name]) : __('hosts.takeover.command_sub') }} +

+
+ + @if ($command === null) + @if ($missingSettings !== []) + {{-- Erst gar nicht ausstellen: eine Zeile ohne Hub-Schlüssel oder + Endpunkt sieht vollständig aus und scheitert erst auf der + Maschine, nach dem Neuaufsetzen der Platten. --}} + +

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

+

{{ __('hosts.takeover.missing_body', ['settings' => implode(', ', $missingSettings)]) }}

+
+ @elseif ($refusal !== null) + +

{{ $refusal }}

+
+ @else +

{{ __('hosts.reissue.invalidates') }}

+ @endif + @else +
+
+

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

+ +
+ + {{-- Umbrechen statt waagerecht rollen: aus einem Kasten mit + Rollbalken markiert jemand die Hälfte und merkt es erst auf der + Maschine. --}} +
{{ $command }}
+
+ +

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

+ @endif + + +
+ @if ($command === null) + {{ __('hosts.cancel') }} + @if ($missingSettings === [] && $refusal === null) + + {{ __('hosts.reissue.confirm') }} + + @endif + @else + {{-- „Fertig" und nicht „Abbrechen": ausgestellt ist ausgestellt, + und das Schließen nimmt nichts zurück. --}} + {{ __('hosts.reissue.done') }} + @endif +
+
+
diff --git a/tests/Feature/Admin/HostManagementTest.php b/tests/Feature/Admin/HostManagementTest.php index a4aba7b..c4aba18 100644 --- a/tests/Feature/Admin/HostManagementTest.php +++ b/tests/Feature/Admin/HostManagementTest.php @@ -4,12 +4,15 @@ use App\Livewire\Admin\ConfirmRemoveHost; use App\Livewire\Admin\HostCreate; use App\Livewire\Admin\HostDetail; use App\Livewire\Admin\Hosts; +use App\Livewire\Admin\ReissueTakeover; +use App\Models\Datacenter; use App\Models\Host; use App\Models\ProvisioningRun; use App\Models\User; use App\Provisioning\Jobs\AdvanceRunJob; use App\Provisioning\Jobs\PurgeHost; use App\Provisioning\Jobs\RemoveWireguardPeer; +use App\Support\HostEnrolment; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; @@ -40,7 +43,7 @@ it('creates a host and starts onboarding with an encrypted password', function ( // das erste Mal bootet (Spec §5). Ohne den gefälschten Hub griffe das nach // `wg` auf einer Maschine, die keine wg0 hat. fakeServices(); - \App\Models\Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); + Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); Livewire::actingAs(admin(), 'operator') ->test(HostCreate::class) @@ -67,7 +70,7 @@ it('creates a host and starts onboarding with an encrypted password', function ( }); it('rejects a duplicate public ip', function () { - \App\Models\Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); + Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); Host::factory()->create(['public_ip' => '203.0.113.99']); Livewire::actingAs(admin(), 'operator') @@ -144,8 +147,8 @@ it('renders the live stepper for a running host', function () { }); it('filters the host list by search and datacenter', function () { - \App\Models\Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); - \App\Models\Datacenter::query()->firstOrCreate(['code' => 'hel'], ['name' => 'Helsinki']); + Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); + Datacenter::query()->firstOrCreate(['code' => 'hel'], ['name' => 'Helsinki']); Host::factory()->create(['name' => 'pve-fsn-9', 'datacenter' => 'fsn', 'public_ip' => '203.0.113.9']); Host::factory()->create(['name' => 'pve-hel-9', 'datacenter' => 'hel', 'public_ip' => '203.0.113.19']); @@ -191,3 +194,118 @@ it('reports host heartbeat health from last_seen_at', function () { ->and($offline->healthState())->toBe('offline') ->and($never->healthState())->toBe('offline'); }); + +/* +|-------------------------------------------------------------------------- +| Die Befehlszeile noch einmal +|-------------------------------------------------------------------------- +| +| Es gab sie genau einmal, auf der Seite direkt nach dem Anlegen. Wer sie dort +| nicht kopierte — oder wem sie wegbrach, wie beim WireGuard-Fehler bis +| 1.3.80 — hatte danach einen Host in der Liste, für den es keinen Weg zu +| einer Befehlszeile mehr gab: ein zweites Anlegen scheitert an der eindeutigen +| IP, also blieb nur Entfernen und von vorn. +*/ + +it('hands out a fresh command line for a host that already exists', function () { + Queue::fake(); + $host = Host::factory()->create(['status' => 'onboarding', 'wg_ip' => null, 'wg_pubkey' => null]); + + $component = Livewire::actingAs(admin(), 'operator') + ->test(ReissueTakeover::class, ['uuid' => $host->uuid]) + ->assertSet('command', null) + ->call('issue'); + + $command = $component->get('command'); + + // Die ganze Zeile, nicht nur der Code — das ist der Punkt: sie trägt auch + // den privaten WireGuard-Schlüssel, die Tunneladresse und den FQDN. + expect($command)->toContain('clupilot-bootstrap.sh') + ->and($command)->toContain('--code ') + ->and($command)->toContain('--wg-private ') + ->and($command)->toContain('--fqdn '); +}); + +it('makes the previously issued code worthless', function () { + // Sonst hätte eine Maschine, die im Rettungssystem hängengeblieben ist, + // weiter einen gültigen Ausweis für einen Host, den der Betreiber gerade + // neu übernimmt. + Queue::fake(); + $host = Host::factory()->create(['status' => 'onboarding', 'wg_ip' => null, 'wg_pubkey' => null]); + $alt = HostEnrolment::issue($host); + + Livewire::actingAs(admin(), 'operator') + ->test(ReissueTakeover::class, ['uuid' => $host->uuid]) + ->call('issue'); + + expect(HostEnrolment::resolve($alt))->toBeNull(); +}); + +it('does not hand the takeover line to an operator who may not manage hosts', function () { + // In der Antwort steht ein privater WireGuard-Schlüssel. Ein Modal ist ohne + // die Route-Middleware der Seite erreichbar (R20), also prüft es selbst. + $host = Host::factory()->create(['status' => 'onboarding']); + + Livewire::actingAs(operator('Read-only'), 'operator') + ->test(ReissueTakeover::class, ['uuid' => $host->uuid]) + ->call('issue') + ->assertForbidden(); +}); + +it('refuses to rotate the credentials of a host that is already running', function () { + // Der Knopf steht dort gar nicht — aber ein Modal, das offen blieb, während + // der Host aktiv wurde, ruft die Methode trotzdem, und eine + // Livewire-Methode ist ohnehin von außen aufrufbar. Ein neuer Schlüssel + // schnitte die laufende Maschine aus dem Tunnel. + Queue::fake(); + $host = Host::factory()->active()->create(['wg_ip' => null, 'wg_pubkey' => null]); + $alt = HostEnrolment::issue($host); + + Livewire::actingAs(admin(), 'operator') + ->test(ReissueTakeover::class, ['uuid' => $host->uuid]) + ->call('issue') + ->assertSet('command', null) + ->assertSet('refusal', __('hosts.reissue.not_eligible')); + + // Und der bisherige Code gilt weiter: eine abgelehnte Ausstellung darf + // nichts entwerten. + expect(HostEnrolment::resolve($alt)?->id)->toBe($host->id); +}); + +it('issues nothing while the tunnel settings are incomplete', function () { + // Sonst wird ein gültiger Code entwertet und dafür eine Zeile ausgegeben, + // der die Tunnel-Angaben fehlen — sie läuft und endet in einem Tunnel ohne + // Handshake, und das fällt erst auf der Maschine auf. + Queue::fake(); + config()->set('provisioning.wireguard.hub_public_key', ''); + + $host = Host::factory()->create(['status' => 'onboarding', 'wg_ip' => null, 'wg_pubkey' => null]); + $alt = HostEnrolment::issue($host); + + Livewire::actingAs(admin(), 'operator') + ->test(ReissueTakeover::class, ['uuid' => $host->uuid]) + ->call('issue') + ->assertSet('command', null) + ->assertSet('refusal', __('hosts.takeover.missing_title')); + + expect(HostEnrolment::resolve($alt)?->id)->toBe($host->id); +}); + +it('treats a drained host as running, not as one waiting for takeover', function () { + // `disabled` sieht aus wie „noch nicht fertig" und ist das Gegenteil: + // toggleMaintenance() schaltet einen LAUFENDEN Host so still, um ihn aus + // der Platzierung zu nehmen. Ein Knopf, der dort eine Neuinstallation + // anbietet, trifft eine Produktionsmaschine. + Queue::fake(); + $host = Host::factory()->active()->create(['status' => 'disabled', 'wg_ip' => null, 'wg_pubkey' => null]); + $alt = HostEnrolment::issue($host); + + Livewire::actingAs(admin(), 'operator') + ->test(ReissueTakeover::class, ['uuid' => $host->uuid]) + ->call('issue') + ->assertSet('command', null) + ->assertSet('refusal', __('hosts.reissue.not_eligible')); + + expect(HostEnrolment::resolve($alt)?->id)->toBe($host->id) + ->and(ReissueTakeover::eligible($host))->toBeFalse(); +});