From 8733686dd3421041667f2c32cb34787bdf46c234 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 19:09:02 +0200 Subject: [PATCH] Fix-Welle Schlussreview: Tunnel-Rettung und Waechter melden Misserfolg ehrlich C1: rescue-tunnel.sh zaehlt Probleme (kein Zugang geladen, conntrack ohne Root) und gibt sie als Exit-Code zurueck, statt bei jedem Fehlschlag Exit 0 zu melden. Die Konsole zeigt jetzt zusaetzlich das Protokoll der letzten Tunnel-Rettung (UpdateChannel::rescueLog(), dasselbe
-Muster wie das Update-Protokoll), und "erfolgreich" ist einem zurueckhaltenderen "durchgelaufen"/"completed" gewichen, das nicht mehr behauptet als das Skript wirklich weiss. I1: watchdog.sh gewann `geheilt=true` bisher unmittelbar nach `up -d`, `--force-recreate` und `artisan up`, ohne nachzupruefen, ob der Griff gewirkt hat. Ein neues `fehlgeschlagen`-Flag laesst einen Misserfolg den Lauf-Ausgang gewinnen, auch wenn ein anderer Zweig im selben Lauf erfolgreich war -- die Konsole zeigt jetzt outcome=tried statt sich hinter outcome=healed zu verstecken. M3: watchdog_stood_down nennt nicht mehr nur "ein Update" als Sperrenhalter -- die Sperre haelt inzwischen auch proxy-hosts, restart, archive-key und rescue-tunnel. M4: das Runbook nennt den Konsolen-Knopf jetzt vor der Kommandozeile. M7: ConfirmRescueTunnel hat jetzt einen Test, nach dem Vorbild von ConfirmReleaseUpdateLock -- mount()s authorize und die Ereignisverdrahtung Modal -> Seite waren ungeprueft. Volle Suite: 3036 bestanden, 0 fehlgeschlagen. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/Settings.php | 5 + app/Services/Deployment/UpdateChannel.php | 35 ++++ deploy/rescue-tunnel.sh | 31 ++++ deploy/watchdog.sh | 70 +++++++- docs/runbooks/tunnel-recovery.md | 13 +- lang/de/admin_settings.php | 22 ++- lang/en/admin_settings.php | 22 ++- .../views/livewire/admin/settings.blade.php | 41 ++++- .../Feature/Admin/ConfirmRescueTunnelTest.php | 83 +++++++++ tests/Feature/RescueTunnelTest.php | 162 +++++++++++++++++- tests/Feature/WatchdogVisibilityTest.php | 107 +++++++++++- 11 files changed, 563 insertions(+), 28 deletions(-) create mode 100644 tests/Feature/Admin/ConfirmRescueTunnelTest.php diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 11e99a7..a43eb93 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -861,6 +861,11 @@ class Settings extends Component return view('livewire.admin.settings', [ 'update' => app(UpdateChannel::class)->state(), 'updateLog' => app(UpdateChannel::class)->lastLog(), + // Review-Runde 2 (C1 Teil 2): derselbe
-Auszug wie + // updateLog, nur fürs Protokoll der letzten Tunnel-Rettung — der + // Betreiber soll den Satz mit dem Handgriff lesen können, ohne + // auf den Wirt zu gehen. + 'rescueLog' => app(UpdateChannel::class)->rescueLog(), // 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(), diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 9aaf55f..70910fb 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -154,6 +154,18 @@ final class UpdateChannel private const RESCUE_LAST_RUN = 'deploy/rescue-last-run.json'; + /** + * Der Protokollauszug der letzten Tunnel-Rettung. + * + * Dasselbe Muster wie LOG/lastLog() weiter unten, nur für + * deploy/rescue-tunnel.sh statt deploy/update.sh — deploy/update-agent.sh + * schreibt die volle Ausgabe des Skripts hierhin (Zeile ~694), Blade + * zeigt sie als
, wie das Protokoll der Aktualisierung auch. + * Review-Runde 2 (C1 Teil 2): der Betreiber soll den Satz mit dem + * Handgriff lesen können, ohne auf den Wirt zu gehen. + */ + private const RESCUE_LOG = 'deploy/rescue-last-run.log'; + /** Written by the agent after every check and every run. */ private const STATUS = 'deploy/update-status.json'; @@ -957,6 +969,29 @@ final class UpdateChannel } } + /** + * The tail of the last tunnel-rescue attempt — the same shape as + * lastLog() above, for RESCUE_LOG instead of LOG. Read separately from + * state(), same as `updateLog` is in App\Livewire\Admin\Settings::render(): + * this is something to show, not something to decide on. + */ + public function rescueLog(int $lines = 40): ?string + { + $path = storage_path('app/'.self::RESCUE_LOG); + + try { + if (! File::exists($path)) { + return null; + } + + $all = preg_split('/\R/', trim((string) File::get($path))) ?: []; + + return implode("\n", array_slice($all, -$lines)); + } catch (Throwable) { + return null; + } + } + /** @return array */ private function readJson(string $relative): array { diff --git a/deploy/rescue-tunnel.sh b/deploy/rescue-tunnel.sh index 94ca63a..d5a231f 100755 --- a/deploy/rescue-tunnel.sh +++ b/deploy/rescue-tunnel.sh @@ -22,6 +22,21 @@ ok() { printf '\033[1;32m ✓\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; } bad() { printf '\033[1;31m ✗\033[0m %s\n' "$*"; } +# Wie viele Stellen etwas gefunden haben, das nicht ausgerichtet ist, wie es +# sein sollte — und zwar am ENDE des Laufs, nicht dem, was ein einzelner Griff +# gerade behoben zu haben glaubt. Der Aufrufer (deploy/update-agent.sh) macht +# bisher NUR den Exit-Code zur Wahrheit; ohne diesen Zähler endete das Skript +# unter `set -uo pipefail` (kein `-e`) nach jedem `bad`/`warn` auf ein +# schlichtes `echo` weiter unten — Exit 0, auch wenn mittendrin etwas offen +# blieb. Nicht jede `bad`/`warn`-Zeile zählt: eine, die einen unmittelbar +# folgenden `exit 1` hat (Container startet nicht, wg0 laesst sich nicht +# hochziehen), braucht den Zähler nicht — der Exit-Code steht da schon fest. +# Und eine rein informative Meldung (dieser Server hat noch keinen eigenen +# Tunnel-Container) sagt nichts darüber, ob DIESER Lauf etwas nicht +# hinbekommen hat — sie stünde bei jedem Lauf auf einem älteren Wirt, auch +# wenn der Tunnel tadellos steht. +probleme=0 + if [[ $EUID -eq 0 ]]; then echo "Bitte als Dienstbenutzer starten, nicht als root:" >&2 echo " sudo -u clupilot bash $0" >&2 @@ -82,7 +97,11 @@ peers="$(hub wg show wg0 peers | grep -c . || true)" if [[ "${peers:-0}" -gt 0 ]]; then ok "$peers Zugänge geladen" else + # Nicht bloss eine Randnotiz: ohne einen einzigen Zugang tut der Tunnel + # nichts, egal wie gesund wg0 sonst aussieht. Vorher endete der Lauf hier + # trotzdem auf Exit 0. warn "Kein einziger Zugang geladen — /etc/wireguard/wg0.conf ansehen." + probleme=$((probleme + 1)) fi # ── 3. Kommt auch etwas an? ────────────────────────────────────────────────── @@ -108,12 +127,18 @@ if [[ "${stumm:-0}" -gt 0 ]]; then if sudo -n conntrack -D -p udp --dport "$WG_PORT" >/dev/null 2>&1; then ok "erledigt — die Zugänge bauen sich in den nächsten 30 Sekunden neu auf" else + # DAS Krankheitsbild, das nie von allein heilt (siehe Kopf dieser + # Datei): stumme Zugänge bleiben stumm, wenn dieser Griff scheitert. + # Der conntrack-Sudoers-Eintrag ist Handarbeit (siehe unten) und auf + # einem frischen Wirt der Normalfall, nicht die Ausnahme — genau + # deshalb darf ein gescheiterter Versuch hier nicht als Erfolg enden. bad "Dafür fehlt mir Root. Bitte einmal von Hand ausführen:" echo echo " sudo conntrack -D -p udp --dport $WG_PORT" echo warn "Damit ich das künftig selbst darf, siehe docs/runbooks/tunnel-recovery.md," warn "Abschnitt 'Einmal einrichten'." + probleme=$((probleme + 1)) fi else ok "Alle Zugänge haben schon Daten geschickt" @@ -136,3 +161,9 @@ echo " wieder auf — und muss danach zurückgenommen werden:" echo echo " /usr/local/sbin/clupilot-emergency-open-firewall.sh" echo + +# Der Aufrufer (deploy/update-agent.sh) macht den Exit-Code zur einzigen +# Wahrheitsquelle — siehe `probleme` oben. Kein Problem gezählt heisst hier +# tatsächlich Exit 0; eins oder mehr heisst, der Lauf hat nicht ausgerichtet, +# was nötig war, auch wenn er bis hierher durchgelaufen ist. +exit "$probleme" \ No newline at end of file diff --git a/deploy/watchdog.sh b/deploy/watchdog.sh index 9ee27bf..0023ae8 100755 --- a/deploy/watchdog.sh +++ b/deploy/watchdog.sh @@ -105,6 +105,16 @@ darf_eingreifen() { geheilt=false +# Ein Misserfolg muss den Ausgang gewinnen — auch wenn im selben Lauf ein +# ANDERER Griff gewirkt hat. Ohne dieses eigene Flag setzte ein erfolgreicher +# Zweig `geheilt=true`, und die Konsole zeigte "eingegriffen" in ruhiger +# Farbe, waehrend ein zweiter Zweig (z. B. wg0) im selben Lauf nicht gewirkt +# hat — der Satz, der genau fuer diesen Fall geschrieben wurde +# (watchdog_tried), wurde nie gedruckt, weil seine Bedingung bisher an +# `outcome === 'idle'` hing und `outcome` durch den erfolgreichen Zweig +# bereits `healed` war. +fehlgeschlagen=false + # Was dieser Lauf getan hat, in der Reihenfolge. Die Konsole liest daraus # einen Satz; das Journal hat weiterhin die Langfassung. AKTIONEN=() @@ -121,9 +131,21 @@ if [[ -n "$soll" ]]; then if [[ -n "$fehlt" ]] && darf_eingreifen; then say "Es fehlen Dienste: $fehlt — starte sie." lange_frist docker compose up -d >/dev/null 2>&1 || true - geheilt=true sleep 10 ist="$(frist docker compose ps --services --status running 2>/dev/null | sort || true)" + + # Zweiter Blick, wie beim wg0-Zweig weiter unten: `up -d` kann + # scheitern (Docker-Daemon nicht erreichbar, ein Abbild fehlt) und + # gab bisher trotzdem `geheilt=true` — die Konsole zeigte dann + # "eingegriffen" in ruhiger Farbe, waehrend der Stapel weiter unten + # lag. + fehlt_nach="$(comm -23 <(printf '%s\n' "$soll") <(printf '%s\n' "$ist") | tr '\n' ' ' | sed 's/ *$//')" + if [[ -z "$fehlt_nach" ]]; then + geheilt=true + else + say "ACHTUNG: es fehlen weiterhin Dienste: $fehlt_nach." + fehlgeschlagen=true + fi fi fi @@ -147,8 +169,19 @@ if printf '%s\n' "$ist" | grep -qx app && printf '%s\n' "$ist" | grep -qx redis; if darf_eingreifen && ! frist docker compose exec -T -u www-data app getent hosts redis >/dev/null 2>&1; then say "Die Container finden einander nicht mehr (redis nicht auflösbar) — erzeuge sie neu." lange_frist docker compose up -d --force-recreate >/dev/null 2>&1 || true - geheilt=true sleep 15 + + # Dritter Blick: hat `--force-recreate` selbst gewirkt? Bisher + # stand `geheilt=true` schon VOR dieser Frage — derselbe Fehler + # wie beim wg0-Zweig, nur an einer teureren Stelle (dieser Griff + # reisst jede offene Verbindung ab, und tat es hier auch dann + # unwidersprochen, wenn Docker den Neubau selbst nicht schaffte). + if frist docker compose exec -T -u www-data app getent hosts redis >/dev/null 2>&1; then + geheilt=true + else + say "ACHTUNG: redis ist weiterhin nicht auflösbar." + fehlgeschlagen=true + fi fi fi fi @@ -176,6 +209,14 @@ if printf '%s\n' "$ist" | grep -qx vpn-hub; then geheilt=true else say "ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md." + # Review-Runde 2: `outcome` blieb bis hierher `idle`, + # wenn kein ANDERER Zweig im selben Lauf gegriffen hatte + # — dann las die Konsole "actions" und zeigte + # watchdog_tried. Griff aber ein anderer Zweig ERFOLGREICH + # (z. B. fehlende Dienste), stand `outcome` schon auf + # `healed`, und dieser Fehlschlag verschwand darin. Das + # eigene Flag gewinnt jetzt unabhaengig davon. + fehlgeschlagen=true fi fi fi @@ -210,14 +251,24 @@ if [[ ! -f "$HOLD" ]] && frist docker compose exec -T -u www-data app test -f st && frist docker compose exec -T -u www-data app test -f storage/framework/down >/dev/null 2>&1; then say "Der Wartungsmodus haengt seit ueber einer halben Stunde ohne laufendes Update — beende ihn." lange_frist docker compose exec -T -u www-data app php artisan up >/dev/null 2>&1 || true - geheilt=true + + # Zweiter Blick, dasselbe Muster wie in den drei Zweigen oben: `up` + # kann scheitern (Docker-Daemon nicht erreichbar), und ohne diese + # Pruefung stand `geheilt=true` bereits fest, waehrend die Seite + # unten blieb. + if ! frist docker compose exec -T -u www-data app test -f storage/framework/down >/dev/null 2>&1; then + geheilt=true + else + say "ACHTUNG: der Wartungsmodus liess sich nicht beenden." + fehlgeschlagen=true + fi fi fi # Nur reden, wenn es etwas zu sagen gab. Ein Waechter, der jede Minute meldet, # dass alles in Ordnung ist, wird nach zwei Tagen nicht mehr gelesen — und dann # auch nicht mehr an dem Tag, an dem er etwas Wichtiges sagt. -if [[ "$geheilt" == true ]]; then +if [[ "$geheilt" == true || "$fehlgeschlagen" == true ]]; then say "Nachgesehen und eingegriffen." fi @@ -229,9 +280,12 @@ fi # die Sperre, der Agent kam nicht an die Arbeit, und die einzige Stelle, an der # das gestanden haette, war von der Konsole aus unerreichbar. # -# Drei Ausgaenge, weil sie drei verschiedene Dinge bedeuten: +# Vier Ausgaenge, weil sie vier verschiedene Dinge bedeuten: # idle — nachgesehen, nichts zu tun. Der Normalfall. -# healed — eingegriffen. Was, steht in `actions`. +# healed — eingegriffen, und JEDER Griff in diesem Lauf hat gewirkt. +# tried — eingegriffen, aber mindestens EIN Griff hat NICHT gewirkt — +# auch wenn ein anderer im selben Lauf erfolgreich war. Ein +# Misserfolg gewinnt den Ausgang; siehe `fehlgeschlagen` oben. # stood_down — nicht drangekommen, weil ein Update die Sperre hielt. # Betrieb, kein Fehler — aber es muss unterscheidbar sein. # @@ -239,7 +293,9 @@ fi # und eine halbe JSON-Datei bricht die Seite in dem Moment, in dem jemand # nachsieht. ausgang=idle -if [[ "$geheilt" == true ]]; then +if [[ "$fehlgeschlagen" == true ]]; then + ausgang=tried +elif [[ "$geheilt" == true ]]; then ausgang=healed elif [[ "$SPERRE" == verwehrt ]]; then ausgang=stood_down diff --git a/docs/runbooks/tunnel-recovery.md b/docs/runbooks/tunnel-recovery.md index b972b83..4325a4d 100644 --- a/docs/runbooks/tunnel-recovery.md +++ b/docs/runbooks/tunnel-recovery.md @@ -85,7 +85,18 @@ kommen, der einen zur Konsole bringt. --- -## Zuerst: ein Befehl, der das meiste allein macht +## Zuerst: der Knopf in der Konsole + +Einstellungen → „Tunnel retten". Derselbe Rettungsversuch wie unten, nur ohne +SSH: die Konsole legt eine Bitte ab, der Update-Agent auf dem Wirt fährt +`deploy/rescue-tunnel.sh` (dasselbe Skript, kein zweiter Weg) und meldet +zurück, was er getan hat — samt Protokollauszug, direkt unter dem Knopf. Der +Griff geht damit vom Wirt in die Konsole, wo dieser ganze Vorgang eigentlich +hingehört. + +Rückfall, wenn die Konsole selbst nicht erreichbar ist (dann zuerst zurück zu +„Die vier Wege hinein" oben) oder wenn man von Hand nachvollziehen will, was +das Skript tut: ```bash cd /opt/clupilot && sudo -u clupilot bash deploy/rescue-tunnel.sh diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 28ca3ba..d944e49 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -144,10 +144,16 @@ return [ // 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.', - // Fix-Runde 1: gegriffen, aber ohne Erfolg — z. B. wg0 blieb trotz - // wg-quick up unten. Ohne diesen Satz verschwand der Fall in - // „nichts zu tun" (watchdog_idle), weil outcome dafür weiterhin idle ist. + // M3: die Sperre halten inzwischen mehr Vorgaenge als ein Update — + // proxy-hosts, restart, archive-key, rescue-tunnel. „weil ein Update + // lief" war damit schon falsch, sobald einer der anderen die Sperre hielt. + 'watchdog_stood_down' => ':when zurückgetreten — ein anderer Vorgang hielt die Sperre.', + // Fix-Runde 1 / Review-Runde 2: gegriffen, aber mindestens ein Griff + // ohne Erfolg — auch wenn ein anderer im selben Lauf gewirkt hat (z. B. + // fehlende Dienste kamen hoch, wg0 blieb trotz wg-quick up unten). Ohne + // diesen Satz verschwand der Fall entweder in „nichts zu tun" + // (watchdog_idle) oder, seit der ersten Fix-Runde, in „eingegriffen" + // (watchdog_healed) — outcome traegt jetzt ein eigenes `tried`. 'watchdog_tried' => 'Zuletzt eingegriffen :when, ohne Erfolg: :what', 'watchdog_stale' => 'Der Wächter hat sich seit :when nicht gemeldet — er läuft vermutlich nicht mehr.', @@ -181,6 +187,14 @@ return [ // Fix-Runde 1: das Ergebnis war bisher unsichtbar — UpdateChannel::state() // lieferte `rescue_last_run`, aber kein Blade las es. 'rescue_last_run' => 'Letzte Tunnel-Rettung: :state, :when.', + // Review-Runde 2 (C1 Teil 3): eigenes Wort statt update_state.succeeded + // ("erfolgreich") — ein durchgelaufenes Skript hat nur geprüft, dass + // jeder Griff ausgerichtet hat, was nötig war (deploy/rescue-tunnel.sh), + // nicht dass der Tunnel jetzt wieder zweifelsfrei Daten empfängt. + 'rescue_state_ok' => 'durchgelaufen', + // Review-Runde 2 (C1 Teil 2): dasselbe Muster wie 'update_log', nur fürs + // Protokoll der letzten Tunnel-Rettung (UpdateChannel::rescueLog()). + 'rescue_log' => 'Protokoll der letzten Tunnel-Rettung', // ── Festnageln ──────────────────────────────────────────────────────── // Die Decke, die den Update-Agenten UND das Wartungsfenster begrenzt diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index d4e8155..f64c155 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -141,10 +141,16 @@ return [ // 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.', - // Fix round 1: intervened, but without success — e.g. wg0 stayed down - // despite wg-quick up. Without this sentence the case disappeared into - // "nothing to do" (watchdog_idle), because outcome is still idle. + // M3: more than an update now holds the lock — proxy-hosts, restart, + // archive-key, rescue-tunnel. "because an update was running" was already + // wrong the moment any of the others held it instead. + 'watchdog_stood_down' => 'Stood down :when — another process held the lock.', + // Fix round 1 / review round 2: intervened, but at least one attempt + // failed — even if another one in the same run worked (e.g. missing + // services came back up, wg0 stayed down despite wg-quick up). Without + // this sentence the case disappeared either into "nothing to do" + // (watchdog_idle) or, since the first fix round, into "intervened" + // (watchdog_healed) — outcome now carries its own `tried`. 'watchdog_tried' => 'Last intervened :when, without success: :what', 'watchdog_stale' => 'The watchdog has not reported in since :when — it is probably no longer running.', @@ -178,6 +184,14 @@ return [ // Fix round 1: the outcome used to be invisible — UpdateChannel::state() // returned `rescue_last_run`, but no blade read it. 'rescue_last_run' => 'Last tunnel rescue: :state, :when.', + // Review round 2 (C1 part 3): its own word instead of update_state.succeeded + // ("succeeded") — a run that completed only confirmed that every step + // lined up what was needed (deploy/rescue-tunnel.sh), not that the tunnel + // is now definitely receiving data again. + 'rescue_state_ok' => 'completed', + // Review round 2 (C1 part 2): same pattern as 'update_log', for the last + // tunnel-rescue attempt's log (UpdateChannel::rescueLog()). + 'rescue_log' => 'Log of the last tunnel rescue', // ── Pinning a ceiling ──────────────────────────────────────────────── // The ceiling that limits both the update agent AND the maintenance diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index 7c41b19..5fc1ab4 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -121,20 +121,23 @@ {{-- Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht — er redet ins Journal, und das liegt auf dem Wirt. --}} @if ($watchdog) - {{-- Fix-Runde 1 (Task 3): ein idle-Ausgang MIT Aktionen ist kein - "nichts zu tun" — der Waechter hat gegriffen, der Griff hat nur - nicht gewirkt (z. B. wg0 blieb trotz wg-quick up unten, siehe - watchdog.sh). Vorher fiel genau dieser Fall auf `watchdog_idle` - durch, weil das die einzige Verzweigung ohne `outcome`-Pruefung - war — eine ruhige graue Zeile waehrend kein Host erreichbar ist. --}} -

+ {{-- Review-Runde 2 (I1): `outcome` traegt jetzt selbst "tried" — + gesetzt in watchdog.sh, sobald mindestens EIN Griff im Lauf + nicht gewirkt hat, auch wenn ein ANDERER im selben Lauf + erfolgreich war (z. B. fehlende Dienste kamen hoch, wg0 aber + nicht). Die Bedingung haengt deshalb nicht mehr an `idle`: vorher + stand "gegriffen, aber wg0 blieb unten" nur dann als + watchdog_tried, wenn KEIN anderer Zweig im selben Lauf gegriffen + hatte — griff einer, war `outcome` schon `healed`, und der + Fehlschlag verschwand darin, ruhig eingefaerbt. --}} +

@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()]) }} - @elseif ($watchdog['actions']) + @elseif ($watchdog['outcome'] === 'tried') {{ __('admin_settings.watchdog_tried', ['when' => $watchdog['at']->local()->diffForHumans(), 'what' => implode('; ', $watchdog['actions'])]) }} @else {{ __('admin_settings.watchdog_idle', ['when' => $watchdog['at']->local()->diffForHumans()]) }} @@ -438,9 +441,18 @@ Protokoll — er musste auf den Wirt, genau dorthin, wovon dieser Knopf ihn befreien sollte. --}} @if ($update['rescue_last_run']) + {{-- Review-Runde 2 (C1 Teil 3): „erfolgreich" (update_state.succeeded) + behauptet mehr, als ein durchgelaufenes Skript weiss — es prüft + am Ende nicht nach, ob der Tunnel jetzt wirklich wieder Daten + empfängt, nur ob jeder einzelne Griff ausgerichtet hat, was nötig + war (siehe deploy/rescue-tunnel.sh). Eigene, bewusst zurückhaltende + Formulierung statt der Beschriftung, die für eine echte + Aktualisierung (git pull, Container neu, Migration) gilt. --}}

{{ __('admin_settings.rescue_last_run', [ - 'state' => __('admin_settings.update_state.'.($update['rescue_last_run']['state'] === 'ok' ? 'succeeded' : 'failed')), + 'state' => $update['rescue_last_run']['state'] === 'ok' + ? __('admin_settings.rescue_state_ok') + : __('admin_settings.update_state.failed'), 'when' => $update['rescue_last_run']['finished_at']?->diffForHumans() ?? '—', ]) }} @if ($update['rescue_last_run']['error']) @@ -449,6 +461,17 @@

@endif + {{-- Review-Runde 2 (C1 Teil 2): dasselbe
-Muster wie das + Protokoll der Aktualisierung darunter, nur für die Tunnel-Rettung — + der Betreiber soll den Satz mit dem Handgriff lesen können, ohne auf + den Wirt zu gehen. --}} + @if ($rescueLog) +
+ {{ __('admin_settings.rescue_log') }} +
{{ $rescueLog }}
+
+ @endif + {{-- Open while it runs: "läuft gerade" on its own tells an operator nothing about whether it is progressing or wedged. --}} @if ($updateLog) diff --git a/tests/Feature/Admin/ConfirmRescueTunnelTest.php b/tests/Feature/Admin/ConfirmRescueTunnelTest.php new file mode 100644 index 0000000..4f25367 --- /dev/null +++ b/tests/Feature/Admin/ConfirmRescueTunnelTest.php @@ -0,0 +1,83 @@ +role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(ConfirmRescueTunnel::class) + ->assertSee(__('admin_settings.rescue_title')); +}); + +it('refuses to even mount the confirmation for an operator without site.manage', function () { + // Eine Livewire-Komponente ist ein oeffentlicher Endpunkt — auch das + // Modal selbst, nicht nur die Aktion, die es am Ende auslöst. + $staff = Operator::factory()->create(); + + Livewire::actingAs($staff, 'operator') + ->test(ConfirmRescueTunnel::class) + ->assertForbidden(); +}); + +it('leaves the deed to the page component, and only dispatches', function () { + // R23: das Modal mutiert nichts. Die Berechtigungsprüfung UND das + // eigentliche Anfordern bleiben an der einen Stelle, an der sie schon + // stehen (App\Livewire\Admin\Settings::rescueTunnel()). + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(ConfirmRescueTunnel::class) + ->call('confirm') + ->assertDispatched('rescue-tunnel-confirmed'); + + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); +}); + +it('leaves a rescue request for the agent once the confirmation comes back', function () { + // Die Ereignis-Verdrahtung Modal → Seite: Settings faengt + // 'rescue-tunnel-confirmed' per #[On(...)] auf, genau wie beim Sperre- + // Loesen daneben. + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(AdminSettings::class) + ->dispatch('rescue-tunnel-confirmed'); + + $request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true); + + expect($request['kind'])->toBe('rescue-tunnel') + ->and($request['requested_by'])->toBe($owner->email); +}); + +it('refuses to rescue the tunnel for an operator without the capability, even via the event', function () { + $staff = Operator::factory()->create(); + + Livewire::actingAs($staff, 'operator') + ->test(AdminSettings::class) + ->dispatch('rescue-tunnel-confirmed') + ->assertForbidden(); + + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); +}); diff --git a/tests/Feature/RescueTunnelTest.php b/tests/Feature/RescueTunnelTest.php index 08041c4..0cfbe86 100644 --- a/tests/Feature/RescueTunnelTest.php +++ b/tests/Feature/RescueTunnelTest.php @@ -4,6 +4,7 @@ use App\Livewire\Admin\Settings; use App\Models\Operator; use App\Services\Deployment\UpdateChannel; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Process; use Livewire\Livewire; /** @@ -21,6 +22,51 @@ afterEach(function () { File::deleteDirectory(storage_path('app/deploy')); }); +/** + * Baut eine `docker`- und `sudo`-Attrappe für deploy/rescue-tunnel.sh und + * gibt ihr Verzeichnis zurück. Hier läuft das ECHTE Skript, nicht eine + * Nachbildung seiner Logik — genau daran ist der letzte Fix schon einmal + * gescheitert (siehe WatchdogVisibilityTest). + * + * @param array $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(); @@ -127,5 +173,119 @@ it('shows a successful rescue attempt too', function () { Livewire::actingAs($owner, 'operator') ->test(Settings::class) - ->assertSee(__('admin_settings.update_state.succeeded')); + ->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')); }); diff --git a/tests/Feature/WatchdogVisibilityTest.php b/tests/Feature/WatchdogVisibilityTest.php index fc71646..4deff39 100644 --- a/tests/Feature/WatchdogVisibilityTest.php +++ b/tests/Feature/WatchdogVisibilityTest.php @@ -36,7 +36,19 @@ function runWatchdog(string $running = "app\nredis", bool $holdLock = false): ar #!/bin/sh case "\$*" in *config*) printf '%s\\n' app redis ;; - *"ps "*) printf '%s\\n' '$running' ;; + *"ps "*) + # Review-Runde 2 (I1): watchdog.sh prueft nach `up -d` jetzt + # NOCH EINMAL nach, ob es gewirkt hat. Ohne diese Reaktion auf + # den eigenen Aufruf blieb "app" fuer den zweiten Blick + # ebenso fehlend wie fuer den ersten, und ein wirklich + # erfolgreicher Heilversuch waere in diesem Test nicht mehr + # von einem gescheiterten zu unterscheiden gewesen. + if [ -f "\$STUB_UP_CALLED" ]; then + printf '%s\\n' app redis + else + printf '%s\\n' '$running' + fi + ;; *getent*) exit 0 ;; *storage/framework/down*) exit 1 ;; *" up "*) touch "\$STUB_UP_CALLED" ;; @@ -116,6 +128,53 @@ function runWatchdogTunnelRescue(bool $recovers): array return json_decode(File::get($dir.'/watchdog-last-run.json'), true); } +/** + * Zwei Zweige im selben Lauf: fehlende Dienste kommen mit `up -d` wieder + * hoch (Zweig 1 heilt erfolgreich), UND wg0 laesst sich trotz `wg-quick up` + * nicht hochziehen (Zweig 3 scheitert). Genau das Szenario aus I1 — ein + * Erfolg und ein Misserfolg im selben Lauf. + */ +function runWatchdogPartialFailure(): array +{ + $dir = storage_path('app/deploy'); + File::ensureDirectoryExists($dir); + File::delete(File::glob($dir.'/*')); + + $stub = $dir.'/stub'; + File::ensureDirectoryExists($stub); + + File::put($stub.'/docker', <<timeout(90)->env([ + 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), + 'STUB_APP_STARTED' => $dir.'/.stub-app-started', + ])->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')); }); @@ -170,10 +229,15 @@ it('shows the operator that an intervention failed, instead of "nothing to do"', // auch beim Betreiber ankommt. Vorher fiel er auf watchdog_idle durch: // eine ruhige graue Zeile "nichts zu tun", waehrend "ACHTUNG" und "wg0" // im HTML gar nicht mehr vorkamen. + // + // Review-Runde 2 (I1): `outcome` traegt seither ein eigenes `tried` statt + // `idle` mit Aktionen — watchdog.sh liefert diese Form fuer genau dieses + // Szenario jetzt selbst (siehe runWatchdogPartialFailure() weiter unten), + // und die Blade-Bedingung haengt nicht mehr an `idle`. File::ensureDirectoryExists(storage_path('app/deploy')); File::put(storage_path('app/deploy/watchdog-last-run.json'), json_encode([ 'at' => now()->utc()->format('Y-m-d\TH:i:s\Z'), - 'outcome' => 'idle', + 'outcome' => 'tried', 'actions' => [ 'wg0 steht nicht — ziehe den Tunnel hoch.', 'ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md.', @@ -191,6 +255,45 @@ it('shows the operator that an intervention failed, instead of "nothing to do"', ->assertDontSee('nichts zu tun'); }); +it('wins the outcome with "tried" even when another branch in the same run healed something', function () { + // I1, auf Datenebene: das ECHTE watchdog.sh, nicht eine Nachbildung + // seiner Logik. Zweig 1 (fehlende Dienste) heilt erfolgreich, Zweig 3 + // (wg0) scheitert im selben Lauf — vorher stand `outcome` dann auf + // `healed`, weil `geheilt=true` schon durch Zweig 1 galt und nichts den + // Fehlschlag aus Zweig 3 mehr zurücknehmen konnte. + $run = runWatchdogPartialFailure(); + + expect($run['outcome'])->toBe('tried') + ->and(implode(' ', $run['actions']))->toContain('Es fehlen Dienste: app — starte sie.') + ->and(implode(' ', $run['actions']))->toContain('ACHTUNG: wg0 liess sich nicht hochziehen'); +}); + +it('does not read as calmly "intervened" when one branch failed, even though another one in the same run healed something', function () { + // I1, im gerenderten HTML: derselbe Zustand, den runWatchdogPartialFailure() + // oben tatsächlich erzeugt. Vor dem Fix stand `outcome` hier auf `healed` + // (Zweig 1 hatte gewirkt) — die Konsole zeigte watchdog_healed in ruhiger + // Farbe, und der ACHTUNG-Satz stand nur noch als Beiwerk im `:what` drin, + // ohne die Kennzeichnung "ohne Erfolg" und ohne text-warning. + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/watchdog-last-run.json'), json_encode([ + 'at' => now()->utc()->format('Y-m-d\TH:i:s\Z'), + 'outcome' => 'tried', + 'actions' => [ + 'Es fehlen Dienste: app — starte sie.', + 'wg0 steht nicht — ziehe den Tunnel hoch.', + 'ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md.', + ], + ])); + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->assertSee('ohne Erfolg', false) + ->assertSee('ACHTUNG: wg0 liess sich nicht hochziehen', false) + ->assertSeeHtml('text-warning') + ->assertDontSee('nichts zu tun'); +}); + it('reads nothing rather than falling over when the file is absent', function () { File::ensureDirectoryExists(storage_path('app/deploy'));