$peers Zeilen für "wg show wg0 peers" (leer = kein Zugang) * @param array $transfer Zeilen für "wg show wg0 transfer" ("pubkey empfangen gesendet") */ function rescueTunnelStub(array $peers = ['PEER1'], array $transfer = ['PEER1 0 999'], bool $sudoOk = true, bool $ownContainer = true): string { $dir = storage_path('app/deploy'); File::ensureDirectoryExists($dir); $stub = $dir.'/stub-rescue'; File::ensureDirectoryExists($stub); $peersOut = implode('\n', $peers); $transferOut = implode('\n', $transfer); $cmdlineOut = $ownContainer ? 'vpn-hub' : 'some-other-process'; File::put($stub.'/docker', <<requestRescueTunnel('chef@example.com'))->toBeTrue(); $request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true); expect($request['kind'])->toBe('rescue-tunnel') ->and($request['requested_by'])->toBe('chef@example.com'); }); it('takes only one request at a time', function () { $channel = app(UpdateChannel::class); expect($channel->requestRescueTunnel('chef@example.com'))->toBeTrue() ->and($channel->requestRescueTunnel('chef@example.com'))->toBeFalse(); }); it('rescues the tunnel from the console', function () { $owner = Operator::factory()->role('Owner')->create(); Livewire::actingAs($owner, 'operator') ->test(Settings::class) ->call('rescueTunnel'); expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeTrue(); }); it('refuses to rescue without site.manage', function () { // Eine Livewire-Aktion ist ein oeffentlicher Endpunkt. $staff = Operator::factory()->create(); Livewire::actingAs($staff, 'operator') ->test(Settings::class) ->call('rescueTunnel') ->assertForbidden(); }); it('reads the outcome without falling over when it is rubbish', function () { File::put(storage_path('app/deploy/rescue-last-run.json'), 'kein json {'); expect(app(UpdateChannel::class)->state()['rescue_last_run'])->toBeNull(); }); it('agent knows the kind', function () { // Der Agent muss die Art kennen, sonst liegt die Anfrage bis zum Ablauf // im Postkasten und niemand erfaehrt warum. expect(File::get(base_path('deploy/update-agent.sh'))) ->toContain('rescue-tunnel') ->toContain('rescue-tunnel.sh'); }); it('agent passes the rescue error through instead of swallowing it', function () { // Fix-Runde 1: `write_status idle` (ohne zweites Argument) verschluckte // RESCUE_ERROR. Der Zweig muss den Fehler weiterreichen, wie proxy-hosts // es daneben schon tut. expect(File::get(base_path('deploy/update-agent.sh'))) ->toContain('write_status idle "$RESCUE_ERROR"'); }); it('shapes the last rescue outcome for the view, with a local timestamp and a translated error', function () { // Fix-Runde 1: das Feld existierte, aber nichts las es. Hier der // Nachweis auf Datenebene, dass die Form stimmt, die das Blade braucht — // `finished_at` als Carbon (R19: die View parst nicht selbst) und // `error` schon uebersetzt (dieselbe errorMessage(), die auch STATUS und // LAST_RUN uebersetzt). File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([ 'state' => 'failed', 'finished_at' => now()->utc()->toIso8601String(), 'error' => 'rescue_failed', ])); $run = app(UpdateChannel::class)->state()['rescue_last_run']; expect($run['state'])->toBe('failed') ->and($run['finished_at'])->toBeInstanceOf(\Illuminate\Support\Carbon::class) ->and($run['error'])->toBe(__('admin_settings.update_error.rescue_failed')); }); it('shows a failed rescue attempt in the console, instead of silence', function () { // Das eigentliche Befund-Szenario: Betreiber drueckt "Tunnel retten", // bekommt "angefordert", der Versuch scheitert auf dem Wirt. Vorher // zeigte die Konsole danach exakt nichts. File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([ 'state' => 'failed', 'finished_at' => now()->utc()->toIso8601String(), 'error' => 'rescue_failed', ])); $owner = Operator::factory()->role('Owner')->create(); Livewire::actingAs($owner, 'operator') ->test(Settings::class) ->assertSee(__('admin_settings.update_state.failed')) ->assertSee(__('admin_settings.update_error.rescue_failed'), false) ->assertSeeHtml('text-danger'); }); it('shows a successful rescue attempt too', function () { File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([ 'state' => 'ok', 'finished_at' => now()->utc()->toIso8601String(), 'error' => '', ])); $owner = Operator::factory()->role('Owner')->create(); Livewire::actingAs($owner, 'operator') ->test(Settings::class) ->assertSee(__('admin_settings.rescue_state_ok')); }); it('does not call a rescue attempt "succeeded" — a run that completed is not proof the tunnel works', function () { // Review-Runde 2 (C1 Teil 3): update_state.succeeded ("erfolgreich") // behauptet mehr, als ein durchgelaufenes rescue-tunnel.sh weiss. Dieser // Test faellt auf den Vorzustand rot: dort las settings.blade.php das // 'ok'-Ergebnis noch als admin_settings.update_state.succeeded. File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([ 'state' => 'ok', 'finished_at' => now()->utc()->toIso8601String(), 'error' => '', ])); $owner = Operator::factory()->role('Owner')->create(); Livewire::actingAs($owner, 'operator') ->test(Settings::class) ->assertDontSee(__('admin_settings.update_state.succeeded')); }); it('shows the rescue log tail in the console, the same way the update log already does', function () { // Review-Runde 2 (C1 Teil 2): der Log-Auszug gehoert in die Konsole, // uebernimmt das Muster von UpdateChannel::lastLog() / 'update_log' // statt eines neu erfundenen. File::put(storage_path('app/deploy/rescue-last-run.log'), "Zeile eins\nACHTUNG: wg0 liess sich nicht hochziehen.\n"); $owner = Operator::factory()->role('Owner')->create(); Livewire::actingAs($owner, 'operator') ->test(Settings::class) ->assertSee(__('admin_settings.rescue_log')) ->assertSee('ACHTUNG: wg0 liess sich nicht hochziehen', false); }); it('shows nothing for the rescue log when no rescue has ever run', function () { $owner = Operator::factory()->role('Owner')->create(); Livewire::actingAs($owner, 'operator') ->test(Settings::class) ->assertDontSee(__('admin_settings.rescue_log')); }); // ── C1 Teil 1: das Skript selbst muss Misserfolg als Misserfolg melden ────── it('exits with a problem when not a single peer is loaded', function () { // Der zweite der beiden im Review genannten Stellen: "Kein einziger // Zugang geladen" endete bisher trotzdem auf Exit 0. $stub = rescueTunnelStub(peers: [], transfer: []); $result = Process::path(base_path())->timeout(60)->env([ 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), ])->run('bash deploy/rescue-tunnel.sh'); expect($result->exitCode())->not->toBe(0); }); it('exits with a problem when stale conntrack entries cannot be cleared for lack of root', function () { // DAS Fehlerszenario aus dem Review: 1 von 1 Zugaengen stumm, `sudo -n // conntrack -D` scheitert (kein sudoers-Eintrag — laut Runbook auf einem // frischen Wirt der Normalfall). Bisher: nur `bad(...)`, Exit 0. $stub = rescueTunnelStub(peers: ['PEER1'], transfer: ['PEER1 0 999'], sudoOk: false); $result = Process::path(base_path())->timeout(60)->env([ 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), ])->run('bash deploy/rescue-tunnel.sh'); expect($result->exitCode())->not->toBe(0) ->and($result->output())->toContain('Dafür fehlt mir Root'); }); it('still exits clean when everything actually lines up, including on a host without its own tunnel container', function () { // Gegenprobe zur Einteilung aus C1 Teil 1: die Meldung ueber einen noch // fehlenden eigenen Tunnel-Container (Zeile 58/59) ist eine reine // Versionsauskunft, kein Fehlschlag DIESES Laufs — sie darf den // Rueckgabewert nicht anfassen, sonst meldete jeder Lauf auf einem // aelteren Wirt "fehlgeschlagen", obwohl der Tunnel tadellos steht. $stub = rescueTunnelStub(peers: ['PEER1'], transfer: ['PEER1 100 999'], sudoOk: true, ownContainer: false); $result = Process::path(base_path())->timeout(60)->env([ 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), ])->run('bash deploy/rescue-tunnel.sh'); expect($result->exitCode())->toBe(0) ->and($result->output())->toContain('eigenen Tunnel-Container noch nicht'); }); it('marks a rescue attempt failed end-to-end when conntrack cannot be cleared, and the console does not call it "succeeded"', function () { // C1, der ganze Weg durch: das ECHTE deploy/update-agent.sh ruft das // ECHTE deploy/rescue-tunnel.sh, genau wie auf dem Wirt. Der Nachweis // wird — wie gefordert — im gerenderten HTML gefuehrt, nicht nur auf // Datenebene: genau dort ist der vorige Fix schon einmal durchgerutscht. $dir = storage_path('app/deploy'); $stub = rescueTunnelStub(peers: ['PEER1'], transfer: ['PEER1 0 999'], sudoOk: false); // `git` scheitert sofort — dieser Tick soll die bereits im Postkasten // liegende Bitte abarbeiten, kein echtes `git fetch` versuchen. File::put($stub.'/git', "#!/bin/sh\nexit 1\n"); Process::run("chmod +x {$stub}/git"); $owner = Operator::factory()->role('Owner')->create(); app(UpdateChannel::class)->requestRescueTunnel($owner->email); $result = Process::path(base_path())->timeout(90)->env([ 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), ])->run('bash deploy/update-agent.sh >/dev/null 2>&1 || true'); expect($result->successful())->toBeTrue($result->errorOutput()); $run = json_decode(File::get($dir.'/rescue-last-run.json'), true); expect($run['state'])->toBe('failed'); Livewire::actingAs($owner, 'operator') ->test(Settings::class) ->assertSee(__('admin_settings.update_state.failed')) ->assertDontSee(__('admin_settings.update_state.succeeded')) ->assertSee(__('admin_settings.rescue_log')); });