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