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