Release v1.3.81 — Host anlegen scheiterte am WireGuard-Peer
tests / pest (push) Failing after 9m3s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details

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 <noreply@anthropic.com>
main v1.3.81
nexxo 2026-08-01 00:43:10 +02:00
parent f1133edcbd
commit 8ab50f5650
5 changed files with 156 additions and 8 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@
.env.bak-*
.phpactor.json
.phpunit.result.cache
/.claude/
/.codex
/.cursor/
/.idea

View File

@ -1 +1 @@
1.3.80
1.3.81

View File

@ -0,0 +1,94 @@
<?php
namespace App\Provisioning\Jobs;
use App\Models\Host;
use App\Services\Wireguard\WireguardHub;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Trägt den Tunnel-Peer eines Hosts auf dem Hub ein.
*
* Auf der Provisioning-Queue, weil nur deren Container `wg0` besitzt genau
* wie ApplyVpnPeer, aus genau demselben Grund.
*
* HostEnrolment tat das bis hierher SELBST, im Web-Request. Der läuft im
* `app`-Container, und der hat weder NET_ADMIN noch `/dev/net/tun`: `wg set`
* antwortete mit „Unable to modify interface: Operation not permitted", und das
* Anlegen eines Hosts endete für den Betreiber in einem 500 auf der einen
* Seite, die ihm den Einmal-Code zeigen sollte. Aufgefallen ist es nie, weil
* die Testsuite den Hub gegen FakeWireguardHub tauscht; die Klasse trug den
* Hinweis darauf sogar im Kopf („verified on the real VM, not in the mocked
* test-suite") und meinte damit einen anderen Aufrufer.
*/
class ApplyHostVpnPeer implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public function __construct(
public int $hostId,
/**
* Der abgelöste Schlüssel, falls die Übernahme ein zweites Mal läuft.
*
* Als Wert und nicht aus der Zeile gelesen: die trägt zum Zeitpunkt des
* Einreihens bereits den NEUEN Schlüssel, der alte stünde dann nirgends
* mehr und bliebe für immer auf dem Hub.
*/
public ?string $previousPublicKey = null,
) {
$this->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(),
]);
}
}

View File

@ -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,

View File

@ -1,9 +1,11 @@
<?php
use App\Models\Host;
use App\Provisioning\Jobs\ApplyHostVpnPeer;
use App\Services\Wireguard\WireguardHub;
use App\Support\HostEnrolment;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
fakeServices();
@ -85,6 +87,52 @@ it('allocates a tunnel address and admits the peer up front', function () {
expect(array_keys(app(WireguardHub::class)->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