diff --git a/app/Http/Controllers/BootstrapArchiveController.php b/app/Http/Controllers/BootstrapArchiveController.php new file mode 100644 index 0000000..df0686f --- /dev/null +++ b/app/Http/Controllers/BootstrapArchiveController.php @@ -0,0 +1,110 @@ +isStale($archive)) { + $this->build($archive); + } + + return response() + ->file($archive, [ + 'Content-Type' => 'application/gzip', + 'Content-Disposition' => 'attachment; filename="bootstrap.tar.gz"', + ]); + } + + /** + * Neuer als alles, was drinsteckt? + * + * Über die Änderungszeit und nicht über einen Hash: das Verzeichnis ist + * klein, der Vergleich läuft bei jedem Abruf, und ein Hash über alle Dateien + * kostete bei jedem Abruf mehr als das Packen gelegentlich kostet. + */ + private function isStale(string $archive): bool + { + if (! is_file($archive)) { + return true; + } + + $built = filemtime($archive); + + foreach ($this->sourceFiles() as $file) { + if (filemtime($file) > $built) { + return true; + } + } + + return false; + } + + /** @return list */ + private function sourceFiles(): array + { + $root = base_path('deploy/bootstrap'); + + if (! is_dir($root)) { + return []; + } + + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $files[] = $file->getPathname(); + } + } + + return $files; + } + + /** + * Mit GNU tar und nicht mit PharData: `phar.readonly` steht in diesem + * Container auf 1, und das Ausführbar-Bit soll erhalten bleiben. + * + * Der oberste Eintrag heißt `bootstrap/`, damit die kopierte Befehlszeile + * mit `tar xz -C /opt/clupilot` genau `/opt/clupilot/bootstrap` ergibt — + * der Ort, an dem das Skript sich selbst und seine Bibliothek erwartet. + */ + private function build(string $archive): void + { + @mkdir(dirname($archive), 0755, true); + + $process = new Process( + ['tar', 'czf', $archive, '-C', base_path('deploy'), 'bootstrap'], + timeout: 60, + ); + + $process->mustRun(); + } +} diff --git a/app/Livewire/Admin/HostCreate.php b/app/Livewire/Admin/HostCreate.php index 7790b40..ddbfaf2 100644 --- a/app/Livewire/Admin/HostCreate.php +++ b/app/Livewire/Admin/HostCreate.php @@ -3,6 +3,8 @@ namespace App\Livewire\Admin; use App\Actions\StartHostOnboarding; +use App\Support\HostEnrolment; +use App\Support\HostTakeoverCommand; use Livewire\Attributes\Layout; use Livewire\Attributes\Validate; use Livewire\Component; @@ -10,6 +12,21 @@ use Livewire\Component; #[Layout('layouts.admin')] class HostCreate extends Component { + /** + * Die fertige Befehlszeile — gesetzt, sobald der Host angelegt ist. + * + * Sie wird GENAU EINMAL gezeigt. In der Datenbank steht nur der Hash des + * Codes, und der private Schlüssel steht dort überhaupt nicht; wer die Seite + * neu lädt, bekommt sie nicht wieder, sondern legt einen neuen Code an. Das + * ist kein Versehen, sondern der Grund, warum ein abgefangener Blick auf + * einen Bildschirm später nichts mehr wert ist. + */ + public ?string $command = null; + + public ?string $createdUuid = null; + + public ?string $createdName = null; + #[Validate('required|string|max:255')] public string $name = ''; @@ -34,13 +51,20 @@ class HostCreate extends Component $host = $action->run($data); - return $this->redirectRoute('admin.hosts.show', ['host' => $host->uuid], navigate: true); + // Kein Weiterleiten mehr. Die Befehlszeile gibt es nur hier und nur + // jetzt — wer den Betreiber auf eine andere Seite schickt, schickt ihn + // von dem einzigen Wert weg, den er braucht. + $this->command = HostTakeoverCommand::for($host, HostEnrolment::issueWithKeys($host)); + $this->createdUuid = $host->uuid; + $this->createdName = $host->name; } public function render() { return view('livewire.admin.host-create', [ 'datacenters' => \App\Models\Datacenter::query()->active()->orderBy('name')->get(), + 'archiveUrl' => HostTakeoverCommand::archiveUrl(), + 'missingSettings' => HostTakeoverCommand::missingSettings(), ]); } } diff --git a/app/Support/HostEnrolment.php b/app/Support/HostEnrolment.php new file mode 100644 index 0000000..7e435a1 --- /dev/null +++ b/app/Support/HostEnrolment.php @@ -0,0 +1,139 @@ +wg_ip ?: $hub->allocateIp(); + + // Der Reihenfolge nach: erst den neuen Peer aufnehmen, dann den alten + // entfernen. Dieselbe Regel wie beim Schlüsseltausch in §6 — dazwischen + // darf es keinen Moment geben, in dem gar kein Peer eingetragen ist. + $previous = $host->wg_pubkey; + $hub->addPeer($keypair->publicKey, $ip); + if (filled($previous) && $previous !== $keypair->publicKey) { + $hub->removePeer($previous); + } + + $code = Str::random(32); + + $host->forceFill([ + 'wg_ip' => $ip, + 'wg_pubkey' => $keypair->publicKey, + 'enrolment_code_hash' => self::hash($code), + 'enrolment_expires_at' => now()->addHours(self::LIFETIME_HOURS), + // Ein neuer Code macht den alten wertlos. 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. + 'enrolment_used_at' => null, + ])->save(); + + return [ + 'code' => $code, + 'private_key' => $keypair->privateKey, + 'public_key' => $keypair->publicKey, + 'wg_ip' => $ip, + ]; + } + + /** + * Löst einen gültigen, unverbrauchten Code auf, OHNE ihn zu verbrauchen. + * + * Das ist der Weg für `/host/progress`: die Fortschrittsmeldungen kommen vor + * der Registrierung, und würden sie den Code verbrauchen, könnte der Host + * sich nach seiner ersten Meldung nie mehr registrieren. + */ + public static function resolve(string $code): ?Host + { + if ($code === '') { + return null; + } + + return Host::query() + ->where('enrolment_code_hash', self::hash($code)) + ->whereNull('enrolment_used_at') + ->where('enrolment_expires_at', '>', now()) + ->first(); + } + + /** Löst einen Code auf und verbraucht ihn. Der Weg für `/host/register`. */ + public static function claim(string $code): ?Host + { + $host = self::resolve($code); + + if ($host === null) { + return null; + } + + $host->forceFill(['enrolment_used_at' => now()])->save(); + + return $host; + } +} diff --git a/app/Support/HostTakeoverCommand.php b/app/Support/HostTakeoverCommand.php new file mode 100644 index 0000000..6272ac8 --- /dev/null +++ b/app/Support/HostTakeoverCommand.php @@ -0,0 +1,103 @@ + + */ + public static function missingSettings(): array + { + $missing = []; + + if (blank(config('provisioning.wireguard.hub_public_key'))) { + $missing[] = 'CLUPILOT_WG_HUB_PUBKEY'; + } + + if (blank(config('provisioning.wireguard.endpoint'))) { + $missing[] = 'CLUPILOT_WG_ENDPOINT'; + } + + if (blank(config('provisioning.wireguard.hub_address'))) { + $missing[] = 'CLUPILOT_WG_HUB_ADDRESS'; + } + + return $missing; + } +} diff --git a/database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php b/database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php new file mode 100644 index 0000000..91e9ecb --- /dev/null +++ b/database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php @@ -0,0 +1,36 @@ +string('enrolment_code_hash', 64)->nullable()->index(); + $table->timestamp('enrolment_expires_at')->nullable(); + $table->timestamp('enrolment_used_at')->nullable(); + }); + } + + public function down(): void + { + Schema::table('hosts', function (Blueprint $table) { + $table->dropIndex(['enrolment_code_hash']); + $table->dropColumn(['enrolment_code_hash', 'enrolment_expires_at', 'enrolment_used_at']); + }); + } +}; diff --git a/lang/de/hosts.php b/lang/de/hosts.php index c91b8f3..f1fdf27 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -134,4 +134,33 @@ return [ 'secure_host_firewall' => 'Host-Firewall absichern', 'complete_host_onboarding' => 'Onboarding abschließen', ], + 'takeover' => [ + 'before_title' => 'Vorher: Rettungssystem starten', + 'before_body' => 'Bestelle den Server beim Anbieter und starte sein Rettungssystem, bevor du hier anlegst. Direkt nach dem Anlegen zeigt diese Seite eine Befehlszeile, die im Rettungssystem eingefügt wird — sie wird genau einmal gezeigt.', + + 'missing_title' => 'Der Tunnel ist noch nicht eingerichtet', + 'missing_body' => 'Es fehlt: :settings. Ohne diese Werte entsteht eine Befehlszeile, die sauber aussieht, läuft — und in einem Tunnel endet, der nie einen Handshake hat. Das fällt erst auf der Maschine auf. Trage sie unter Einstellungen ein, bevor du einen Host anlegst.', + + 'title' => ':name ist angelegt', + 'subtitle' => 'Drei Schritte, danach macht der Server den Rest allein.', + + 'once_title' => 'Diese Zeile gibt es nur jetzt', + 'once_body' => 'In der Datenbank steht nur der Hash des Codes, der WireGuard-Schlüssel steht dort gar nicht. Wer die Seite verlässt oder neu lädt, bekommt sie nicht wieder, sondern legt einen neuen Code an — der alte ist damit wertlos.', + + 'step1_title' => 'Rettungssystem starten', + 'step1_body' => 'Im Kundenbereich des Anbieters das Rettungssystem einschalten und den Server neu starten. Einschalten allein genügt nicht: er muss wirklich darin hochgefahren sein, sonst weigert sich das Skript — es überschreibt Platten und prüft deshalb zuerst, ob es das darf.', + + 'step2_title' => 'Diese Zeile im Rettungssystem einfügen', + 'step2_body' => 'Per SSH als root auf den Server, Zeile einfügen, Eingabetaste. Es wird nichts abgetippt und nichts ausgefüllt: sie trägt alles, was der Server vor dem Tunnel braucht.', + 'step2_hint' => 'Die Zeile holt das Installationsskript von :url, packt es nach /opt/clupilot aus und startet es. Mehr lädt der Server nicht nach.', + + 'step3_title' => 'Zusehen', + 'step3_body' => 'Der Fortschritt läuft in der Konsole mit, Abschnitt für Abschnitt. Bis der Tunnel steht, meldet der Server nichts — das ist so gewollt und dauert über den ersten Neustart hinweg. Danach kommt alles Vorherige auf einmal nach, mit den Zeiten von damals.', + + 'watch' => 'Fortschritt ansehen', + 'copy' => 'Kopieren', + 'copied' => 'Kopiert', + + 'footnote' => 'Der Code gilt 24 Stunden und lässt sich genau einmal einlösen. Bleibt die Übernahme stehen, wird die Maschine neu aufgesetzt und nicht nachgebessert — was wo nachzusehen ist, steht im Runbook unter docs/runbooks/host-bootstrap.md.', + ], ]; diff --git a/lang/en/hosts.php b/lang/en/hosts.php index 0527c9e..0f4e0b6 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -134,4 +134,33 @@ return [ 'secure_host_firewall' => 'Secure host firewall', 'complete_host_onboarding' => 'Complete onboarding', ], + 'takeover' => [ + 'before_title' => 'First: boot the rescue system', + 'before_body' => 'Order the server at the provider and boot its rescue system before you add it here. Right after you add it, this page shows a command line to paste into the rescue system — shown exactly once.', + + 'missing_title' => 'The tunnel is not set up yet', + 'missing_body' => 'Missing: :settings. Without these, the command line looks clean, runs, and ends in a tunnel that never handshakes — which only shows up on the machine. Fill them in under Settings before adding a host.', + + 'title' => ':name is created', + 'subtitle' => 'Three steps, then the server does the rest on its own.', + + 'once_title' => 'This line exists only now', + 'once_body' => 'The database holds only the hash of the code, and not the WireGuard key at all. Leaving or reloading this page does not bring it back — it mints a new code, and the old one becomes worthless.', + + 'step1_title' => 'Boot the rescue system', + 'step1_body' => 'Enable the rescue system in the provider\'s panel and restart the server. Enabling alone is not enough: it has to actually be running in it, or the script refuses — it overwrites disks, so it checks first whether it may.', + + 'step2_title' => 'Paste this line into the rescue system', + 'step2_body' => 'SSH in as root, paste the line, press enter. Nothing is typed out and nothing is filled in: it carries everything the server needs before the tunnel exists.', + 'step2_hint' => 'The line fetches the installer from :url, unpacks it to /opt/clupilot and starts it. The server downloads nothing else.', + + 'step3_title' => 'Watch', + 'step3_body' => 'Progress appears in the console, section by section. Until the tunnel is up the server reports nothing — that is intended, and it lasts across the first reboot. Everything before it then arrives at once, carrying the times it actually happened.', + + 'watch' => 'Watch progress', + 'copy' => 'Copy', + 'copied' => 'Copied', + + 'footnote' => 'The code is valid for 24 hours and can be redeemed exactly once. If the takeover stops, the machine is reinstalled rather than repaired — where to look is in the runbook at docs/runbooks/host-bootstrap.md.', + ], ]; diff --git a/resources/views/livewire/admin/host-create.blade.php b/resources/views/livewire/admin/host-create.blade.php index 46215cd..6566f9b 100644 --- a/resources/views/livewire/admin/host-create.blade.php +++ b/resources/views/livewire/admin/host-create.blade.php @@ -1,43 +1,128 @@ -
-
- - {{ __('hosts.back') }} - -

{{ __('hosts.create_title') }}

-

{{ __('hosts.create_sub') }}

-
- -
- - -
- - - @error('datacenter')

{{ $message }}

@enderror -
- - - - - -
- - {{ __('hosts.cancel') }} +
+ @if ($command === null) +
+ + {{ __('hosts.back') }} - - {{ __('hosts.save') }} - {{ __('hosts.save') }}… - +

{{ __('hosts.create_title') }}

+

{{ __('hosts.create_sub') }}

- + + {{-- Was der Betreiber VORHER wissen muss, steht vor dem Formular und + nicht danach: das Rettungssystem zu starten dauert beim Anbieter ein + paar Minuten. Wer das erst hinterher liest, hat den Code schon in + der Zwischenablage und wartet. --}} + +

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

+

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

+
+ + @if ($missingSettings) + +

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

+

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

+
+ @endif + +
+ + +
+ + + @error('datacenter')

{{ $message }}

@enderror +
+ + + + + +
+ + {{ __('hosts.cancel') }} + + + {{ __('hosts.save') }} + {{ __('hosts.save') }}… + +
+ + @else + {{-- Der Host steht. Ab hier ist diese Seite eine Anleitung und kein + Formular mehr — und sie ist die EINZIGE Stelle, an der die + Befehlszeile je zu sehen ist. --}} +
+

{{ __('hosts.takeover.title', ['name' => $createdName]) }}

+

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

+
+ + +

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

+

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

+
+ +
    +
  1. +
    + 1 +

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

    +
    +

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

    +
  2. + +
  3. +
    + 2 +

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

    +
    +

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

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

    {{ __('hosts.takeover.step2_hint', ['url' => $archiveUrl]) }}

    +
    +
  4. + +
  5. +
    + 3 +

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

    +
    +

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

    + +
  6. +
+ +

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

+ @endif
diff --git a/routes/web.php b/routes/web.php index 006570e..21c24b6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ redirect()->route('security', status: 301)); + // Das Bootstrap-Skript für die Host-Übernahme. Muss öffentlich sein: es + // wird aus dem Rettungssystem eines nackten Servers geholt, der noch keinen + // Tunnel hat und nichts kennt als curl. Eine statische Datei ohne + // Geheimnisse — die reisen in der Befehlszeile mit, die der Adminbereich + // zeigt. + Route::get('/bootstrap.tar.gz', BootstrapArchiveController::class)->name('bootstrap.archive'); + Route::get('/robots.txt', function () { $body = App\Support\Settings::bool('site.public', true) ? "User-agent: *\nAllow: /\n" diff --git a/tests/Feature/Admin/HostManagementTest.php b/tests/Feature/Admin/HostManagementTest.php index 1d29ffd..a4aba7b 100644 --- a/tests/Feature/Admin/HostManagementTest.php +++ b/tests/Feature/Admin/HostManagementTest.php @@ -35,6 +35,11 @@ it('lists real hosts for admins', function () { it('creates a host and starts onboarding with an encrypted password', function () { Queue::fake(); + // Das Anlegen erzeugt jetzt auch das WireGuard-Schlüsselpaar und nimmt den + // Peer beim Hub auf, damit der Host schon im Tunnel bekannt ist, bevor er + // 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']); Livewire::actingAs(admin(), 'operator') @@ -44,7 +49,11 @@ it('creates a host and starts onboarding with an encrypted password', function ( ->set('public_ip', '203.0.113.7') ->set('root_password', 'supersecret') ->call('save') - ->assertRedirect(); + // KEINE Weiterleitung mehr, und das ist der Punkt: die Befehlszeile für + // das Rettungssystem gibt es nur hier und nur jetzt. Wer den Betreiber + // weiterschickt, schickt ihn von dem einzigen Wert weg, den er braucht. + ->assertNoRedirect() + ->assertSee('clupilot-bootstrap.sh'); $host = Host::query()->where('name', 'pve-fsn-7')->first(); expect($host)->not->toBeNull()->and($host->status)->toBe('pending'); diff --git a/tests/Feature/Admin/HostTakeoverPageTest.php b/tests/Feature/Admin/HostTakeoverPageTest.php new file mode 100644 index 0000000..e0ec0bd --- /dev/null +++ b/tests/Feature/Admin/HostTakeoverPageTest.php @@ -0,0 +1,146 @@ +set('provisioning.wireguard.hub_public_key', 'HUBKEYHUBKEYHUBKEYHUBKEYHUBKEYHUBKEYHUBKEY0='); + config()->set('provisioning.wireguard.endpoint', 'hub.clupilot.cloud:51820'); + config()->set('provisioning.wireguard.hub_address', '10.66.0.1'); + config()->set('provisioning.wireguard.subnet', '10.66.0.0/24'); + config()->set('admin_access.app_host', 'clupilot.cloud'); + + // firstOrCreate: die Migrationen legen Rechenzentren schon an, und ein + // zweites `fsn` verletzt den eindeutigen Index. + Datacenter::query()->firstOrCreate( + ['code' => 'fsn'], + ['name' => 'Falkenstein', 'location' => 'DE', 'active' => true], + ); +}); + +function createHostAs(): \Livewire\Features\SupportTesting\Testable +{ + return Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator') + ->test(HostCreate::class) + ->set('name', 'pve-fsn-2') + ->set('datacenter', 'fsn') + ->set('public_ip', '198.51.45.9') + ->set('root_password', 'ein-langes-kennwort') + ->call('save'); +} + +/** + * Die eine Frage, die diese Seite beantworten muss: was gebe ich auf dem Server + * ein? Steht die Zeile nicht da, ist die ganze Übernahme nicht benutzbar. + */ +it('shows the command exactly once, right after creating the host', function () { + $page = createHostAs(); + + $page->assertSee('curl') + ->assertSee('clupilot-bootstrap.sh') + ->assertSee('--code'); + + // Ein frisch geladenes Bauteil — also jemand, der die Seite neu lädt — hat + // nichts mehr. Der Code steht nur als Hash in der Datenbank. + Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator') + ->test(HostCreate::class) + ->assertDontSee('curl'); +}); + +/** + * Die Zeile muss ALLES tragen, was das Skript vor dem Tunnel braucht (Spec §5). + * Fehlt ein Wert, läuft sie an und endet in einem Tunnel ohne Handshake — und + * das merkt man erst auf einer Maschine, die schon bestellt ist. + */ +it('carries every value the script needs before the tunnel', function () { + $command = createHostAs()->get('command'); + + foreach (['--code', '--wg-private', '--wg-ip', '--hub-pubkey', '--hub-endpoint', '--api'] as $argument) { + expect($command)->toContain($argument); + } + + expect($command) + ->toContain('hub.clupilot.cloud:51820') + ->toContain('--api http://10.66.0.1') + ->toContain('/opt/clupilot'); +}); + +/** + * Über den ÖFFENTLICHEN Hostnamen. Die Konsole läuft unter admin.…, aber die + * Zeile wird auf einer Maschine ausgeführt, die den Adminbereich nicht erreichen + * darf — sie ist genau dafür abgeriegelt. Ein Link auf den Konsolen-Hostnamen + * liefe in eine 404 auf einem Server, an den man dann nur noch über die + * Anbieterkonsole kommt. + */ +it('fetches the installer over the public hostname', function () { + expect(createHostAs()->get('command')) + ->toContain('https://clupilot.cloud/bootstrap.tar.gz') + ->not->toContain('admin.'); +}); + +/** + * Der Schlüssel ist base64 und enthält +, / und =. Ohne Anführungszeichen + * zerlegt die Shell die Zeile an einer Stelle, die niemand sieht. + */ +it('quotes the keys so a shell cannot split them', function () { + $command = createHostAs()->get('command'); + + expect($command)->toMatch("/--wg-private '[^']+'/") + ->and($command)->toMatch("/--hub-pubkey '[^']+'/"); +}); + +/** + * Der Code aus der Zeile muss der sein, mit dem der Host sich meldet. Sonst ist + * die Anleitung richtig und der Ablauf trotzdem kaputt. + */ +it('shows a code that actually resolves to the new host', function () { + $command = createHostAs()->get('command'); + + preg_match('/--code ([A-Za-z0-9]+)/', $command, $matches); + + expect(HostEnrolment::claim($matches[1])?->name)->toBe('pve-fsn-2'); +}); + +/** + * Ein leerer Hub-Schlüssel ergibt eine Zeile, die läuft und nie handshaked. + * Das gehört gesagt, BEVOR jemand einen Server bestellt. + */ +it('warns before creating a host when the tunnel settings are missing', function () { + config()->set('provisioning.wireguard.hub_public_key', ''); + config()->set('provisioning.wireguard.endpoint', ''); + + Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator') + ->test(HostCreate::class) + ->assertSee('CLUPILOT_WG_HUB_PUBKEY') + ->assertSee('CLUPILOT_WG_ENDPOINT'); +}); + +/** + * Die drei Schritte sind die Anleitung. Fehlt einer, fehlt genau der, den + * jemand nicht von selbst weiß — meistens das Rettungssystem. + */ +it('spells out the three steps, rescue system first', function () { + createHostAs() + ->assertSee(__('hosts.takeover.step1_title')) + ->assertSee(__('hosts.takeover.step2_title')) + ->assertSee(__('hosts.takeover.step3_title')); +}); + +it('refuses to create a host without the permission', function () { + Livewire::actingAs(Operator::factory()->create(), 'operator') + ->test(HostCreate::class) + ->set('name', 'pve-fsn-3') + ->set('datacenter', 'fsn') + ->set('public_ip', '198.51.45.10') + ->set('root_password', 'ein-langes-kennwort') + ->call('save') + ->assertForbidden(); + + expect(Host::query()->where('name', 'pve-fsn-3')->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Host/BootstrapArchiveTest.php b/tests/Feature/Host/BootstrapArchiveTest.php new file mode 100644 index 0000000..5312245 --- /dev/null +++ b/tests/Feature/Host/BootstrapArchiveTest.php @@ -0,0 +1,78 @@ +get('/bootstrap.tar.gz'); + + $response->assertOk(); + expect($response->headers->get('Content-Type'))->toBe('application/gzip'); +}); + +/** + * Der oberste Eintrag MUSS `bootstrap/` heißen. Die kopierte Zeile packt mit + * `tar xz -C /opt/clupilot` aus, und das Skript sucht sich selbst unter + * `/opt/clupilot/bootstrap` — ein anderer Name landet daneben, und der Fehler + * zeigt sich als „lib/report.sh nicht gefunden" auf einer Maschine, an die man + * nur über die Anbieterkonsole kommt. + */ +it('unpacks to bootstrap/ with the script and its library', function () { + $this->get('/bootstrap.tar.gz')->assertOk(); + + $listing = shell_exec('tar tzf '.escapeshellarg(storage_path('app/bootstrap.tar.gz'))); + + expect($listing) + ->toContain('bootstrap/clupilot-bootstrap.sh') + ->toContain('bootstrap/lib/report.sh') + ->toContain('bootstrap/lib/proxmox.sh') + ->toContain('bootstrap/lib/network.sh') + ->toContain('bootstrap/lib/traefik.sh') + ->toContain('bootstrap/lib/template.sh') + ->toContain('bootstrap/lib/register.sh') + ->toContain('bootstrap/assets/docker-compose.yml'); +}); + +/** + * Es darf nichts Vertrauliches darin stehen. Alle Geheimnisse reisen in der + * Befehlszeile mit, die der Adminbereich genau einmal zeigt. + */ +it('carries no secrets', function () { + $this->get('/bootstrap.tar.gz')->assertOk(); + + $dir = sys_get_temp_dir().'/bootstrap-pruefung-'.bin2hex(random_bytes(4)); + mkdir($dir); + shell_exec('tar xzf '.escapeshellarg(storage_path('app/bootstrap.tar.gz')).' -C '.escapeshellarg($dir)); + + $content = ''; + foreach (glob($dir.'/bootstrap/{,lib/,assets/}*', GLOB_BRACE) as $file) { + if (is_file($file)) { + $content .= file_get_contents($file); + } + } + + expect($content)->not->toBeEmpty() + ->and($content)->not->toContain(config('app.key')) + ->and($content)->not->toContain((string) env('SECRETS_KEY')); + + shell_exec('rm -rf '.escapeshellarg($dir)); +}); + +/** + * Ein Archiv, das einmal gebaut wurde und danach liegen bleibt, ist die Fassung + * von dem Tag, an dem jemand zuletzt daran gedacht hat. + */ +it('rebuilds itself when the script changes', function () { + $this->get('/bootstrap.tar.gz')->assertOk(); + $first = filemtime(storage_path('app/bootstrap.tar.gz')); + + // Eine Sekunde zurück, damit die Änderung sichtbar später liegt. + touch(storage_path('app/bootstrap.tar.gz'), $first - 10); + touch(base_path('deploy/bootstrap/clupilot-bootstrap.sh')); + + $this->get('/bootstrap.tar.gz')->assertOk(); + + expect(filemtime(storage_path('app/bootstrap.tar.gz')))->toBeGreaterThan($first - 10); +}); diff --git a/tests/Feature/Host/HostEnrolmentTest.php b/tests/Feature/Host/HostEnrolmentTest.php new file mode 100644 index 0000000..7ac65c6 --- /dev/null +++ b/tests/Feature/Host/HostEnrolmentTest.php @@ -0,0 +1,119 @@ +create(); + + $code = HostEnrolment::issue($host); + + expect($code)->toHaveLength(32); + + $stored = DB::table('hosts')->where('id', $host->id)->value('enrolment_code_hash'); + expect($stored)->not->toBe($code) + ->and($stored)->not->toContain($code); +}); + +it('resolves a valid code to its host and consumes it', function () { + $host = Host::factory()->create(); + $code = HostEnrolment::issue($host); + + expect(HostEnrolment::claim($code)?->id)->toBe($host->id); + + // Verbraucht. Ein zweiter Lauf braucht einen neuen Code aus der Konsole — + // eine halb installierte Maschine wird neu aufgesetzt, nicht nachgebessert. + expect(HostEnrolment::claim($code))->toBeNull(); +}); + +/** + * Der Fortschritt kommt VOR der Registrierung und darf den Code deshalb nicht + * verbrauchen — sonst könnte der Host sich nach seiner ersten Meldung nie mehr + * registrieren. + */ +it('resolves a code without consuming it, for the progress reports', function () { + $host = Host::factory()->create(); + $code = HostEnrolment::issue($host); + + expect(HostEnrolment::resolve($code)?->id)->toBe($host->id) + ->and(HostEnrolment::resolve($code)?->id)->toBe($host->id); + + // Und danach ist er immer noch einlösbar. + expect(HostEnrolment::claim($code)?->id)->toBe($host->id); +}); + +it('refuses a code past its expiry', function () { + $host = Host::factory()->create(); + $code = HostEnrolment::issue($host); + + $this->travel(25)->hours(); + + expect(HostEnrolment::claim($code))->toBeNull(); +}); + +it('refuses a code that was never issued', function () { + expect(HostEnrolment::claim(str_repeat('a', 32)))->toBeNull(); +}); + +/** + * Der Hub muss den Peer kennen, BEVOR der Host im Tunnel ist — sonst käme er + * nie hinein und müsste seinen Schlüssel über einen öffentlichen Weg melden. + * Genau das vermeidet dieser Entwurf. + */ +it('allocates a tunnel address and admits the peer up front', function () { + $host = Host::factory()->create(['wg_ip' => null, 'wg_pubkey' => null]); + + HostEnrolment::issue($host); + + $host->refresh(); + expect($host->wg_ip)->not->toBeNull() + ->and($host->wg_pubkey)->not->toBeNull(); + + // `peers()` ist nach dem öffentlichen Schlüssel indiziert, die Werte sind + // Momentaufnahmen — geprüft wird deshalb gegen die Schlüssel. + expect(array_keys(app(WireguardHub::class)->peers()))->toContain($host->wg_pubkey); +}); + +/** + * Der private Schlüssel steht in der kopierten Befehlszeile und sonst nirgends. + * Ihn zu speichern hieße, ein Geheimnis aufzubewahren, das nach Task 9 des + * Skripts ohnehin wertlos ist — und bis dahin eine Stelle mehr, an der es liegt. + */ +it('returns the private key with the code and never stores it', function () { + $host = Host::factory()->create(); + + $enrolment = HostEnrolment::issueWithKeys($host); + + expect($enrolment['private_key'])->not->toBeEmpty(); + + $row = (array) DB::table('hosts')->where('id', $host->id)->first(); + foreach ($row as $value) { + expect((string) $value)->not->toContain($enrolment['private_key']); + } +}); + +/** + * Ein zweiter Code macht den ersten wertlos. 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. + */ +it('invalidates the previous code when a new one is issued', function () { + $host = Host::factory()->create(); + + $first = HostEnrolment::issue($host); + $second = HostEnrolment::issue($host); + + expect(HostEnrolment::claim($first))->toBeNull() + ->and(HostEnrolment::claim($second)?->id)->toBe($host->id); +});