From 903ebdd2b2e38c4bcc216fdc306c557f5d22b63f Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 20:40:19 +0200 Subject: [PATCH] Give the operator one line to copy and three steps around it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console could describe a takeover it had no way to start. This is the vertical slice that closes that: a one-time code, the archive the rescue system fetches, and the page that says what to do with both. The command carries EVERYTHING the script needs before the tunnel exists, because there is nothing to fetch — that is the whole point of spec §5. Which means CluPilot generates the WireGuard keypair and admits the peer at the hub before the machine has ever booted, and hands the private half over in the line. It is worthless within minutes: task 9 of the script replaces it with one generated on the machine. Shown exactly once. The database holds only the code's hash and never the private key, so leaving the page does not bring it back — it mints a new code, which invalidates the old one. That is deliberate: a glance at somebody's screen should be worth nothing an hour later. Which is also why save() no longer redirects. Sending the operator to the host detail page sends them away from the only value they need, and an existing test asserted that redirect — it now asserts the opposite, with the reason written next to it. SHA-256 rather than bcrypt for the code, and the reason is not speed. Both endpoints have to FIND the host by the code; with bcrypt that means trying every row. The code is 32 characters of CSPRNG output, so it has the entropy that stretching exists to manufacture. resolve() and claim() are separate because progress reports arrive BEFORE registration. If reporting consumed the code, a host could never register after its first message. The archive URL is always the public hostname. The console runs under admin.…, but this line executes on a machine that must not reach the admin area — it is locked down for exactly that reason — so route() from the console would emit a hostname that 404s on a server only reachable through the provider's console. The page warns about missing tunnel settings BEFORE the host is created, not after. An empty hub key produces a line that looks clean, copies fine, runs, and ends in a tunnel that never handshakes — discovered on the machine, after somebody has already paid for it. The three steps lead with the rescue system, because that is the one nobody knows by heart, and it says enabling is not the same as booting into it — the script refuses a running production machine, which is what a half-done switch looks like from the inside. 1986 tests pass. Co-Authored-By: Claude Opus 5 --- .../BootstrapArchiveController.php | 110 ++++++++++++ app/Livewire/Admin/HostCreate.php | 26 ++- app/Support/HostEnrolment.php | 139 +++++++++++++++ app/Support/HostTakeoverCommand.php | 103 +++++++++++ ...08_02_090000_give_a_host_its_enrolment.php | 36 ++++ lang/de/hosts.php | 29 +++ lang/en/hosts.php | 29 +++ .../livewire/admin/host-create.blade.php | 165 +++++++++++++----- routes/web.php | 8 + tests/Feature/Admin/HostManagementTest.php | 11 +- tests/Feature/Admin/HostTakeoverPageTest.php | 146 ++++++++++++++++ tests/Feature/Host/BootstrapArchiveTest.php | 78 +++++++++ tests/Feature/Host/HostEnrolmentTest.php | 119 +++++++++++++ 13 files changed, 957 insertions(+), 42 deletions(-) create mode 100644 app/Http/Controllers/BootstrapArchiveController.php create mode 100644 app/Support/HostEnrolment.php create mode 100644 app/Support/HostTakeoverCommand.php create mode 100644 database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php create mode 100644 tests/Feature/Admin/HostTakeoverPageTest.php create mode 100644 tests/Feature/Host/BootstrapArchiveTest.php create mode 100644 tests/Feature/Host/HostEnrolmentTest.php 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); +});