From 8ab50f5650f85fae2d9822e9aed4cf8ac9d95cc5 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 00:43:10 +0200 Subject: [PATCH] =?UTF-8?q?Release=20v1.3.81=20=E2=80=94=20Host=20anlegen?= =?UTF-8?q?=20scheiterte=20am=20WireGuard-Peer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Anlegen eines Hosts endete in der Konsole mit einem 500, bevor der Betreiber den Einmal-Code je zu sehen bekam. Ursache war nicht das Anlegen, sondern der Tunnel-Peer: HostEnrolment::issueWithKeys() rief `wg set wg0` selbst auf — im Web-Request, also im `app`-Container. Der hat weder NET_ADMIN noch /dev/net/tun; wg0 lebt im Provisioning-Container. Die Antwort war "Unable to modify interface: Operation not permitted". Der Peer geht jetzt als ApplyHostVpnPeer auf die Provisioning-Queue — genau dorthin, wo ApplyVpnPeer es für die VPN-Zugänge längst richtig macht, mit derselben `wireguard:hub`-Sperre und demselben Grundsatz: der Sollzustand kommt beim Ausführen aus der Zeile, nicht aus dem beim Einreihen festgehaltenen Wert. Der abgelöste Schlüssel reist als Wert mit, weil in der Zeile zu diesem Zeitpunkt schon der neue steht. Aufgefallen ist es nie, weil die Testsuite den Hub gegen FakeWireguardHub tauscht — der bestehende Test blieb grün, während der echte Weg seit jeher fehlschlug. Die zwei neuen Tests prüfen deshalb den WEG statt des Ergebnisses: in der Anfrage bleibt der Hub unberührt, und der Auftrag liegt auf der Provisioning-Queue. Codex: 0 Fehler, 0 Sicherheitsbefunde. Dazu sein P2 — /.claude/ stand weder im Index noch in .gitignore, ein `git add -A` hätte 153 MB als verschachteltes Repo eingebettet. Co-Authored-By: Claude Opus 5 --- .gitignore | 1 + VERSION | 2 +- app/Provisioning/Jobs/ApplyHostVpnPeer.php | 94 ++++++++++++++++++++++ app/Support/HostEnrolment.php | 19 +++-- tests/Feature/Host/HostEnrolmentTest.php | 48 +++++++++++ 5 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 app/Provisioning/Jobs/ApplyHostVpnPeer.php 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