From 9d1811beea8d26128c50a301a9e0970ffc4e9c68 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 17:59:39 +0200 Subject: [PATCH] Tunnel-Rettung aus der Konsole, Waechter und Wirt-Helfer sichtbar Neue Anfrageart rescue-tunnel im bestehenden Update-Postkasten (kein zweiter Weg): UpdateChannel::requestRescueTunnel(), der Agent fuehrt deploy/rescue-tunnel.sh mit Frist aus, die Konsole zeigt Ergebnis, Waechter-Stand und Wirt-Helfer-Vertrag in der Update-Karte, Bestaetigung im Modal (R23) nach dem Muster von ConfirmReleaseUpdateLock. Zusatzfix in deploy/watchdog.sh: der wg0-Block meldete geheilt=true schon, bevor geprueft war, ob wg-quick up wg0 wirklich gewirkt hat -- ein fehlgeschlagener Tunnelaufbau haette der Konsole "healed" vorgemacht. geheilt wird jetzt erst nach der zweiten wg-show-Probe gesetzt, mit Regressionstest in WatchdogVisibilityTest. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/ConfirmRescueTunnel.php | 33 +++++++++ app/Livewire/Admin/Settings.php | 26 +++++++ app/Services/Deployment/UpdateChannel.php | 26 +++++++ deploy/update-agent.sh | 28 +++++++ deploy/watchdog.sh | 8 +- lang/de/admin_settings.php | 23 ++++++ lang/en/admin_settings.php | 23 ++++++ .../admin/confirm-rescue-tunnel.blade.php | 17 +++++ .../views/livewire/admin/settings.blade.php | 39 ++++++++++ tests/Feature/RescueTunnelTest.php | 73 +++++++++++++++++++ tests/Feature/WatchdogVisibilityTest.php | 70 ++++++++++++++++++ 11 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 app/Livewire/Admin/ConfirmRescueTunnel.php create mode 100644 resources/views/livewire/admin/confirm-rescue-tunnel.blade.php create mode 100644 tests/Feature/RescueTunnelTest.php diff --git a/app/Livewire/Admin/ConfirmRescueTunnel.php b/app/Livewire/Admin/ConfirmRescueTunnel.php new file mode 100644 index 0000000..fb3fdb2 --- /dev/null +++ b/app/Livewire/Admin/ConfirmRescueTunnel.php @@ -0,0 +1,33 @@ +authorize('site.manage'); + } + + public function confirm(): void + { + $this->authorize('site.manage'); + + $this->dispatch('rescue-tunnel-confirmed'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-rescue-tunnel'); + } +} diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 77baa34..11e99a7 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -12,6 +12,7 @@ use App\Models\VpnPeer; use App\Provisioning\Jobs\ApplyVpnPeer; use App\Services\Deployment\UpdateChannel; use App\Services\Deployment\UpdateWindow; +use App\Services\Deployment\WatchdogLog; use App\Services\Terminal\ServerIdentity; use App\Services\Terminal\ServerTerminalSetup; use App\Services\Terminal\ServerTicket; @@ -814,6 +815,28 @@ class Settings extends Component : 'admin_settings.release_lock_already_requested')); } + /** + * Den Tunnel wieder hinbekommen. + * + * Der Auslöser kommt aus dem Bestätigungs-Modal (R23); die Prüfung steht + * hier, wie bei jeder anderen Handlung dieser Seite. + */ + #[On('rescue-tunnel-confirmed')] + public function rescueTunnel(): void + { + $this->authorize('site.manage'); + + if (! $operator = $this->currentOperator()) { + return; + } + + $accepted = app(UpdateChannel::class)->requestRescueTunnel($operator->email); + + $this->dispatch('notify', message: __($accepted + ? 'admin_settings.rescue_requested' + : 'admin_settings.update_already_requested')); + } + public function render() { $operator = $this->currentOperator(); @@ -838,6 +861,9 @@ class Settings extends Component return view('livewire.admin.settings', [ 'update' => app(UpdateChannel::class)->state(), 'updateLog' => app(UpdateChannel::class)->lastLog(), + // Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht — + // er redet ins Journal, und das liegt auf dem Wirt. + 'watchdog' => app(WatchdogLog::class)->lastRun(), 'sitePublic' => AppSettings::bool('site.public', true), 'canManageSite' => $operator?->can('site.manage') ?? false, // Shown so nobody has to guess why they still see the real site. diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 58eb505..77f4719 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -142,6 +142,18 @@ final class UpdateChannel */ private const ARCHIVE_KEY = 'deploy/archive-key.json'; + /** + * Den Tunnel wieder hinbekommen. + * + * `deploy/rescue-tunnel.sh` stand in zwei Runbooks als Handarbeit. Es + * läuft als Dienstbenutzer und tut ausdrücklich nichts Zerstörerisches — + * kein Neubau, kein Neustart des Tunnel-Containers, weil genau das die + * Ursache wäre und nicht die Lösung. + */ + private const KIND_RESCUE_TUNNEL = 'rescue-tunnel'; + + private const RESCUE_LAST_RUN = 'deploy/rescue-last-run.json'; + /** Written by the agent after every check and every run. */ private const STATUS = 'deploy/update-status.json'; @@ -407,6 +419,11 @@ final class UpdateChannel 'last_restart_state' => $restartLastRun['state'] ?? null, 'last_restart_finished_at' => $this->timestamp($restartLastRun['finished_at'] ?? null), 'last_restart_error' => $this->errorMessage($restartLastRun), + + // `readJson` fängt kaputtes JSON bereits ab und liefert `[]`. + 'rescue_last_run' => ($last = $this->readJson(self::RESCUE_LAST_RUN)) !== [] + ? $last + : null, ]; } @@ -604,6 +621,15 @@ final class UpdateChannel return $this->submit($by, self::KIND_CHECK); } + /** + * Den Tunnel wieder hinbekommen — kein zweiter Weg, derselbe Postkasten + * wie jede andere Anfrageart. + */ + public function requestRescueTunnel(string $by): bool + { + return $this->submit($by, self::KIND_RESCUE_TUNNEL); + } + /** * Den Server auf eine Version festnageln — oder die Decke abnehmen. * diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index 77f4b0e..7ac9f8c 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -681,6 +681,34 @@ if [[ "$REQUEST_KIND" == "proxy-hosts" ]]; then exit 0 fi +if [[ "$REQUEST_KIND" == "rescue-tunnel" ]]; then + # Läuft als derselbe Dienstbenutzer wie dieser Agent — genau so, wie das + # Runbook es von Hand vorsah. Das Skript weigert sich als root zu laufen. + # + # Frist, weil dieser Aufruf die Sperre hält: die Tunnel-Rettung fasst + # `docker compose exec` an, und ein hängender Docker-Daemon hätte den + # Agenten sonst stundenlang blockiert — dieselbe Ausfallart, die diese + # Anlage schon zweimal hatte. + RESCUE_STATE=ok + RESCUE_ERROR='' + if ! timeout -k 10 180 bash "$ROOT/deploy/rescue-tunnel.sh" > "$STATE_DIR/rescue-last-run.log" 2>&1; then + RESCUE_STATE=failed + RESCUE_ERROR=rescue_failed + fi + + cat > "$STATE_DIR/rescue-last-run.json.tmp" </dev/null 2>&1 || true - geheilt=true + # `geheilt` erst HIER, nach dem zweiten Blick: die Konsole + # zeigt `outcome` inzwischen einem Menschen (Task 3). Vorher + # stand die Zeile vor dieser Pruefung — ein fehlgeschlagenes + # `wg-quick up wg0` meldete sich trotzdem als "healed", weil + # der Text im ACHTUNG-Zweig darunter das `outcome` nicht mehr + # aendern konnte. Der Satz stimmte, das Feld nicht. if frist docker compose exec -T vpn-hub wg show wg0 >/dev/null 2>&1; then say "wg0 steht wieder." + geheilt=true else say "ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md." fi diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 541f2d8..b30c2a8 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -139,6 +139,18 @@ return [ 'update_agent_blocked' => 'Der Update-Dienst läuft, kommt aber seit :since nicht an die Arbeit — ein anderer Vorgang hält die Sperre. Solange das so ist, stammen die Angaben oben von vorher. Löst es sich nicht von selbst, hilft ein Blick auf den Prozess unten.', 'update_log' => 'Protokoll des letzten Laufs', + // ── Der Wächter ────────────────────────────────────────────────────── + // Redet bisher nur ins Journal auf dem Wirt — die Konsole sah ihn bis + // August 2026 gar nicht (App\Services\Deployment\WatchdogLog). + 'watchdog_idle' => 'Zuletzt nachgesehen :when — nichts zu tun.', + 'watchdog_healed' => 'Zuletzt eingegriffen :when: :what', + 'watchdog_stood_down' => ':when zurückgetreten, weil ein Update lief.', + 'watchdog_stale' => 'Der Wächter hat sich seit :when nicht gemeldet — er läuft vermutlich nicht mehr.', + + // ── Der Wirt-Helfer ────────────────────────────────────────────────── + // Der root-eigene Helfer auf dem Wirt (UpdateChannel::state()['host_step_ok']). + 'host_step_old' => 'Der Wirt-Helfer erfüllt Vertrag :have, gebraucht wird :needs. Einmalig auf diesem Wirt ausführen:', + // ── Die Sperre lösen ───────────────────────────────────────────────── // Der Griff, der bisher per SSH auf dem Wirt lag. Er beendet einen // laufenden Prozess, deshalb nennt die Rückfrage ihn vorher beim Namen. @@ -152,6 +164,17 @@ return [ 'release_lock_requested' => 'Wird gelöst — der Dienst meldet sich binnen einer Minute zurück.', 'release_lock_already_requested' => 'Ist schon angefordert — bitte kurz warten.', + // ── Tunnel-Rettung ─────────────────────────────────────────────────── + // deploy/rescue-tunnel.sh stand bisher in zwei Runbooks als Handarbeit. + // Derselbe Postkasten wie jede andere Anfrageart + // (UpdateChannel::KIND_RESCUE_TUNNEL) — kein zweiter Weg. + 'rescue_action' => 'Tunnel retten', + 'rescue_requested' => 'Tunnel-Rettung angefordert.', + 'rescue_title' => 'Den Tunnel jetzt retten?', + 'rescue_body' => 'Zieht das WireGuard-Interface auf dem Tunnel-Server neu hoch. Tut ausdrücklich nichts Zerstörerisches — kein Neubau, kein Neustart des Tunnel-Containers.', + 'rescue_cancel' => 'Abbrechen', + 'rescue_confirm' => 'Tunnel retten', + // ── Festnageln ──────────────────────────────────────────────────────── // Die Decke, die den Update-Agenten UND das Wartungsfenster begrenzt // (App\Services\Deployment\UpdateChannel::setCeiling()). Bestätigt wird diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index ada1cd6..c8880c2 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -136,6 +136,18 @@ return [ 'update_agent_blocked' => 'The update service is running but has been unable to work since :since — another process holds the lock. While that lasts, the figures above are from before. If it does not clear on its own, look at the process named below.', 'update_log' => 'Log of the last run', + // ── The watchdog ───────────────────────────────────────────────────── + // It used to talk only to the host's journal — the console could not see + // it until August 2026 (App\Services\Deployment\WatchdogLog). + 'watchdog_idle' => 'Last checked :when — nothing to do.', + 'watchdog_healed' => 'Last intervened :when: :what', + 'watchdog_stood_down' => 'Stood down :when because an update was running.', + 'watchdog_stale' => 'The watchdog has not reported in since :when — it is probably no longer running.', + + // ── The host helper ────────────────────────────────────────────────── + // The root-owned helper on the host (UpdateChannel::state()['host_step_ok']). + 'host_step_old' => 'The host helper meets contract :have, this version needs :needs. Run once on this host:', + // ── Releasing the lock ─────────────────────────────────────────────── // The handle that used to live behind an SSH session. It ends a running // process, so the confirmation names it before it does. @@ -149,6 +161,17 @@ return [ 'release_lock_requested' => 'Releasing — the service reports back within a minute.', 'release_lock_already_requested' => 'Already requested — please wait a moment.', + // ── Rescuing the tunnel ────────────────────────────────────────────── + // deploy/rescue-tunnel.sh used to be manual work written down in two + // runbooks. The same mailbox as every other request kind + // (UpdateChannel::KIND_RESCUE_TUNNEL) — no second path. + 'rescue_action' => 'Rescue tunnel', + 'rescue_requested' => 'Tunnel rescue requested.', + 'rescue_title' => 'Rescue the tunnel now?', + 'rescue_body' => 'Raises the WireGuard interface on the tunnel server again. Deliberately does nothing destructive — no rebuild, no restart of the tunnel container.', + 'rescue_cancel' => 'Cancel', + 'rescue_confirm' => 'Rescue tunnel', + // ── Pinning a ceiling ──────────────────────────────────────────────── // The ceiling that limits both the update agent AND the maintenance // window (App\Services\Deployment\UpdateChannel::setCeiling()). Only diff --git a/resources/views/livewire/admin/confirm-rescue-tunnel.blade.php b/resources/views/livewire/admin/confirm-rescue-tunnel.blade.php new file mode 100644 index 0000000..8b0edea --- /dev/null +++ b/resources/views/livewire/admin/confirm-rescue-tunnel.blade.php @@ -0,0 +1,17 @@ +
+
+ + + +
+

{{ __('admin_settings.rescue_title') }}

+

{{ __('admin_settings.rescue_body') }}

+
+
+
+ {{ __('admin_settings.rescue_cancel') }} + + {{ __('admin_settings.rescue_confirm') }} + +
+
diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index a24c59f..0c6563f 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -117,6 +117,35 @@
{{ $update['checked_at']->diffForHumans() }}
@endif + + {{-- Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht — er redet + ins Journal, und das liegt auf dem Wirt. --}} + @if ($watchdog) +

+ @if ($watchdog['stale']) + {{ __('admin_settings.watchdog_stale', ['when' => $watchdog['at']->local()->diffForHumans()]) }} + @elseif ($watchdog['outcome'] === 'healed') + {{ __('admin_settings.watchdog_healed', ['when' => $watchdog['at']->local()->diffForHumans(), 'what' => implode('; ', $watchdog['actions'])]) }} + @elseif ($watchdog['outcome'] === 'stood_down') + {{ __('admin_settings.watchdog_stood_down', ['when' => $watchdog['at']->local()->diffForHumans()]) }} + @else + {{ __('admin_settings.watchdog_idle', ['when' => $watchdog['at']->local()->diffForHumans()]) }} + @endif +

+ @endif + + {{-- Der Wirt-Helfer. Steht VOR den Knoepfen, nicht als Fehler danach: wer + gleich „Sperre loesen" drueckt, soll vorher wissen, dass es scheitern + wird. --}} + @unless ($update['host_step_ok']) + + {{ __('admin_settings.host_step_old', [ + 'have' => $update['host_step_have'], + 'needs' => $update['host_step_needs'], + ]) }} + sudo bash /opt/clupilot/deploy/install-agent.sh + + @endunless {{-- Bound, not @disabled(): the directive compiles to inline PHP @@ -174,6 +203,16 @@ {{ __('admin_settings.update_now') }} + {{-- Tunnel-Rettung, in derselben Handlungsleiste wie die übrigen — + dieselbe Begründung wie beim Festnageln daneben (flex-wrap, + w-full sm:w-auto). Bestätigt im Modal (R23), weil es einen + laufenden Zustand auf dem Wirt anfasst. --}} + + + {{ __('admin_settings.rescue_action') }} + + {{-- Festnageln, in derselben Handlungsleiste wie Prüfen/Aktualisieren statt einer zweiten Leiste daneben — dieselbe Begründung wie bei den beiden Knöpfen selbst (flex-wrap, w-full sm:w-auto). Kein diff --git a/tests/Feature/RescueTunnelTest.php b/tests/Feature/RescueTunnelTest.php new file mode 100644 index 0000000..862257d --- /dev/null +++ b/tests/Feature/RescueTunnelTest.php @@ -0,0 +1,73 @@ +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'); +}); diff --git a/tests/Feature/WatchdogVisibilityTest.php b/tests/Feature/WatchdogVisibilityTest.php index 718e833..e5485ae 100644 --- a/tests/Feature/WatchdogVisibilityTest.php +++ b/tests/Feature/WatchdogVisibilityTest.php @@ -61,6 +61,58 @@ function runWatchdog(string $running = "app\nredis", bool $holdLock = false): ar return json_decode(File::get($dir.'/watchdog-last-run.json'), true); } +/** + * Der wg0-Block, isoliert: vpn-hub laeuft, seine Konfiguration existiert, + * "wg show wg0" scheitert — der Waechter darf eingreifen und ruft + * "wg-quick up wg0". Ob der Griff wirklich gewirkt hat, entscheidet + * $recovers: danach meldet "wg show wg0" entweder wieder oben (true) oder + * weiterhin unten (false) — genau der Fall aus dem Task-1-Review, in dem + * `geheilt=true` VOR dieser zweiten Pruefung stand. + */ +function runWatchdogTunnelRescue(bool $recovers): array +{ + $dir = storage_path('app/deploy'); + File::ensureDirectoryExists($dir); + File::delete(File::glob($dir.'/*')); + + $stub = $dir.'/stub'; + File::ensureDirectoryExists($stub); + + $wgShowAfterUp = $recovers ? 'exit 0' : 'exit 1'; + + File::put($stub.'/docker', <<timeout(90)->env([ + 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), + 'STUB_UP_CALLED' => $dir.'/.stub-up-called', + 'STUB_WG_UP_CALLED' => $dir.'/.stub-wg-up-called', + ])->run('bash deploy/watchdog.sh >/dev/null 2>&1 || true'); + + expect($result->successful())->toBeTrue($result->errorOutput()); + + return json_decode(File::get($dir.'/watchdog-last-run.json'), true); +} + afterEach(function () { File::deleteDirectory(storage_path('app/deploy')); }); @@ -91,6 +143,24 @@ it('records that it stood down because the lock was held', function () { expect($run['outcome'])->toBe('stood_down'); }); +it('reports healed when raising wg0 actually brought the tunnel back', function () { + $run = runWatchdogTunnelRescue(recovers: true); + + expect($run['outcome'])->toBe('healed') + ->and(implode(' ', $run['actions']))->toContain('wg0 steht wieder'); +}); + +it('does not claim healed when wg-quick ran but the tunnel stayed down', function () { + // Der Befund aus dem Task-1-Review: `geheilt` wurde wahr, bevor geprueft + // war, ob `wg-quick up wg0` ueberhaupt gewirkt hat. Die Konsole zeigt + // `outcome` jetzt einem Menschen — eine Luege hier waere ein Fehler, kein + // Journal-Detail mehr. + $run = runWatchdogTunnelRescue(recovers: false); + + expect($run['outcome'])->not->toBe('healed') + ->and(implode(' ', $run['actions']))->toContain('ACHTUNG: wg0 liess sich nicht hochziehen'); +}); + it('reads nothing rather than falling over when the file is absent', function () { File::ensureDirectoryExists(storage_path('app/deploy'));