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 <details>-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 <noreply@anthropic.com>
main
nexxo 2026-08-04 19:09:02 +02:00
parent 1a5843670c
commit 8733686dd3
11 changed files with 563 additions and 28 deletions

View File

@ -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 <details>-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(),

View File

@ -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 <details>, 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<string, mixed> */
private function readJson(string $relative): array
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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. --}}
<p class="mt-2 text-sm {{ $watchdog['stale'] || ($watchdog['outcome'] === 'idle' && $watchdog['actions']) ? 'text-warning' : 'text-muted' }}">
{{-- 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. --}}
<p class="mt-2 text-sm {{ $watchdog['stale'] || $watchdog['outcome'] === 'tried' ? 'text-warning' : 'text-muted' }}">
@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. --}}
<p class="mt-3 text-xs {{ $update['rescue_last_run']['state'] === 'failed' ? 'text-danger' : 'text-muted' }}">
{{ __('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 @@
</p>
@endif
{{-- Review-Runde 2 (C1 Teil 2): dasselbe <details>-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)
<details class="mt-4">
<summary class="cursor-pointer text-sm font-medium text-accent-text">{{ __('admin_settings.rescue_log') }}</summary>
<pre class="mt-2 max-h-64 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs leading-relaxed text-body">{{ $rescueLog }}</pre>
</details>
@endif
{{-- Open while it runs: "läuft gerade" on its own tells an operator
nothing about whether it is progressing or wedged. --}}
@if ($updateLog)

View File

@ -0,0 +1,83 @@
<?php
use App\Livewire\Admin\ConfirmRescueTunnel;
use App\Livewire\Admin\Settings as AdminSettings;
use App\Models\Operator;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
/**
* M7 (Review-Runde 2): alle fünf Geschwister-Modals (ConfirmSaveSecret,
* ConfirmForgetSecret, ConfirmReissueVpnPeer, ConfirmDisableTwoFactor,
* ConfirmReleaseUpdateLock) haben einen Test dieses hatte keinen. `mount()`s
* `authorize` und die Ereignis-Verdrahtung Modal Seite waren ungeprüft.
*
* Nach dem Vorbild von tests/Feature/Admin/UpdateLockReleaseTest.php.
*/
beforeEach(function () {
File::ensureDirectoryExists(storage_path('app/deploy'));
File::delete(File::glob(storage_path('app/deploy/*')));
});
afterEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
it('lets an operator with site.manage open the confirmation', function () {
$owner = Operator::factory()->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();
});

View File

@ -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<int, string> $peers Zeilen für "wg show wg0 peers" (leer = kein Zugang)
* @param array<int, string> $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', <<<SH
#!/bin/sh
case "\$*" in
*"ps --status running vpn-hub"*) printf '%s\\n' vpn-hub ;;
*"vpn-hub sh -c"*) printf '%s\\n' '$cmdlineOut' ;;
*"vpn-hub wg-quick up wg0"*) exit 0 ;;
*"vpn-hub test -f /etc/wireguard/wg0.conf"*) exit 0 ;;
*"vpn-hub wg show wg0 peers"*) printf '%s\\n' '$peersOut' ;;
*"vpn-hub wg show wg0 transfer"*) printf '%s\\n' '$transferOut' ;;
*"vpn-hub wg show wg0"*) exit 0 ;;
*"vpn-hub ls -l /etc/wireguard/"*) exit 0 ;;
esac
exit 0
SH);
File::put($stub.'/sudo', $sudoOk
? "#!/bin/sh\nexit 0\n"
: "#!/bin/sh\nexit 1\n");
Process::run("chmod +x {$stub}/docker {$stub}/sudo");
return $stub;
}
it('puts a rescue request in the same mailbox as every other kind', function () {
// Kein zweiter Weg: dieselbe Datei, derselbe Agent, derselbe Lauf.
expect(app(UpdateChannel::class)->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'));
});

View File

@ -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', <<<SH
#!/bin/sh
case "\$*" in
*config*) printf '%s\\n' app redis ;;
*"ps "*)
if [ -f "\$STUB_APP_STARTED" ]; then
printf '%s\\n' app redis vpn-hub
else
printf '%s\\n' redis vpn-hub
fi
;;
*"vpn-hub test -f /etc/wireguard/wg0.conf"*) exit 0 ;;
*"vpn-hub wg-quick up wg0"*) exit 0 ;;
*"vpn-hub wg show wg0"*) exit 1 ;;
*getent*) exit 0 ;;
*storage/framework/down*) exit 1 ;;
*" up "*) touch "\$STUB_APP_STARTED" ;;
esac
exit 0
SH);
Process::run("chmod +x {$stub}/docker");
$result = Process::path(base_path())->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'));