diff --git a/.gitignore b/.gitignore index 77382a6..f9c3b68 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ .env.bak-* .phpactor.json .phpunit.result.cache +/.claude/ /.codex /.cursor/ /.idea diff --git a/VERSION b/VERSION index 6421ca1..258a5b8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.80 +1.3.81 diff --git a/app/Provisioning/Jobs/ApplyHostVpnPeer.php b/app/Provisioning/Jobs/ApplyHostVpnPeer.php new file mode 100644 index 0000000..2795de5 --- /dev/null +++ b/app/Provisioning/Jobs/ApplyHostVpnPeer.php @@ -0,0 +1,94 @@ +onQueue('provisioning'); + } + + public function handle(WireguardHub $hub): void + { + // Derselbe Sperrname wie in ApplyVpnPeer und SyncVpnPeers: ein Abgleich + // darf den Hub nicht mitten in dieser Änderung lesen und danach auf das + // handeln, was er gesehen hat. + Cache::lock('wireguard:hub', 30)->block(10, fn () => $this->apply($hub)); + } + + private function apply(WireguardHub $hub): void + { + // Der Sollzustand kommt aus der Zeile, nicht aus dem beim Einreihen + // festgehaltenen Wert. Ein Wiederholungsversuch nach einem zweiten + // Übernahmeversuch trüge sonst den überholten Schlüssel wieder ein. + $host = Host::find($this->hostId); + + if ($host === null || blank($host->wg_pubkey) || blank($host->wg_ip)) { + return; + } + + // Erst den neuen Peer aufnehmen, dann den alten entfernen — dazwischen + // darf es keinen Moment geben, in dem gar kein Peer eingetragen ist. + $hub->addPeer($host->wg_pubkey, $host->wg_ip); + + if (filled($this->previousPublicKey) && $this->previousPublicKey !== $host->wg_pubkey) { + $hub->removePeer($this->previousPublicKey); + } + } + + /** + * Laut scheitern. + * + * Der Betreiber hat seine Befehlszeile längst in der Hand; bleibt der Peer + * aus, kommt der Tunnel auf der übernommenen Maschine nie hoch, und das + * sieht dort aus wie ein Netzproblem. Diese Zeile ist die einzige Stelle, + * an der steht, dass es keines war. + */ + public function failed(?Throwable $e): void + { + Log::error('Der Tunnel-Peer eines Hosts liess sich nicht eintragen', [ + 'host' => $this->hostId, + 'exception' => $e?->getMessage(), + ]); + } +} diff --git a/app/Support/HostEnrolment.php b/app/Support/HostEnrolment.php index 7e435a1..df950e1 100644 --- a/app/Support/HostEnrolment.php +++ b/app/Support/HostEnrolment.php @@ -3,6 +3,7 @@ namespace App\Support; use App\Models\Host; +use App\Provisioning\Jobs\ApplyHostVpnPeer; use App\Services\Wireguard\Keypair; use App\Services\Wireguard\WireguardHub; use Illuminate\Support\Str; @@ -73,14 +74,7 @@ final class HostEnrolment // alte für immer belegt zu lassen. $ip = $host->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); @@ -95,6 +89,17 @@ final class HostEnrolment 'enrolment_used_at' => null, ])->save(); + // Der Peer wird NACH dem Speichern eingereiht, und nicht hier + // eingetragen: `wg set` gelingt nur im Provisioning-Container, dem + // einzigen mit NET_ADMIN und `wg0`. Der Web-Request lief dagegen im + // `app`-Container — dort scheiterte er, und das Anlegen eines Hosts + // endete in einem 500, bevor der Betreiber seinen Code je zu sehen bekam. + // + // Nach dem Speichern, weil der Auftrag den Sollzustand aus der Zeile + // liest. Bei `QUEUE_CONNECTION=sync` — der Testsuite — läuft er sofort, + // und die Reihenfolge ist dann nicht Stilfrage, sondern Bedingung. + ApplyHostVpnPeer::dispatch($host->id, $previous); + return [ 'code' => $code, 'private_key' => $keypair->privateKey, diff --git a/tests/Feature/Host/HostEnrolmentTest.php b/tests/Feature/Host/HostEnrolmentTest.php index 7ac65c6..885f380 100644 --- a/tests/Feature/Host/HostEnrolmentTest.php +++ b/tests/Feature/Host/HostEnrolmentTest.php @@ -1,9 +1,11 @@ peers()))->toContain($host->wg_pubkey); }); +/** + * Der Riegel gegen den Rückbau. + * + * `wg set` gelingt nur im Provisioning-Container — dem einzigen mit NET_ADMIN, + * `/dev/net/tun` und `wg0`. Diese Methode lief aber im Web-Request, also im + * `app`-Container: „Unable to modify interface: Operation not permitted", und + * das Anlegen eines Hosts endete für den Betreiber in einem 500 — ausgerechnet + * auf der Seite, die ihm den Einmal-Code zeigen sollte. + * + * Der Test davor blieb dabei grün, weil die Suite den Hub gegen + * FakeWireguardHub tauscht. Dieser hier prüft deshalb den WEG, nicht das + * Ergebnis: der Peer gehört auf die Queue, und in der Anfrage bleibt der Hub + * unberührt. + */ +it('registers the tunnel peer on the provisioning queue, not in the request', function () { + Queue::fake(); + + $host = Host::factory()->create(['wg_ip' => null, 'wg_pubkey' => null]); + + HostEnrolment::issue($host); + + Queue::assertPushedOn('provisioning', ApplyHostVpnPeer::class, + fn (ApplyHostVpnPeer $job) => $job->hostId === $host->id); + + expect(app(WireguardHub::class)->peers())->toBeEmpty(); +}); + +it('carries the replaced key along so the old peer does not linger', function () { + // Ein zweiter Übernahmeversuch tauscht den Schlüssel. Stünde der alte + // nicht im Auftrag, läse der ihn aus der Zeile — wo längst der neue steht — + // und der abgelöste Peer bliebe für immer auf dem Hub. + $host = Host::factory()->create(['wg_ip' => null, 'wg_pubkey' => null]); + + HostEnrolment::issue($host); + $erster = $host->refresh()->wg_pubkey; + + HostEnrolment::issue($host); + $zweiter = $host->refresh()->wg_pubkey; + + expect($zweiter)->not->toBe($erster); + + $keys = array_keys(app(WireguardHub::class)->peers()); + expect($keys)->toContain($zweiter) + ->and($keys)->not->toContain($erster); +}); + /** * 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