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..a43eb93 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,14 @@ 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(),
'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 20590ec..70910fb 100644
--- a/app/Services/Deployment/UpdateChannel.php
+++ b/app/Services/Deployment/UpdateChannel.php
@@ -142,6 +142,30 @@ 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';
+
+ /**
+ * 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';
@@ -325,6 +349,20 @@ final class UpdateChannel
'remote_commit' => isset($status['remote_commit']) ? (string) $status['remote_commit'] : null,
'checked_at' => $checkedAt,
+ // Der root-eigene Helfer auf dem Wirt. Fehlt die Meldung ganz
+ // (alte Agentenfassung, allererster Lauf), gilt er als in
+ // Ordnung: „ich weiß es nicht" ist nicht „zu alt", und ein
+ // Warnkasten, der auf jedem frisch aufgesetzten Wirt steht, wird
+ // nach zwei Tagen nicht mehr gelesen.
+ 'host_step_have' => isset($status['host_step_contract'])
+ ? (int) $status['host_step_contract']
+ : null,
+ 'host_step_needs' => isset($status['host_step_needs'])
+ ? (int) $status['host_step_needs']
+ : null,
+ 'host_step_ok' => ! isset($status['host_step_contract'], $status['host_step_needs'])
+ || (int) $status['host_step_contract'] >= (int) $status['host_step_needs'],
+
'agent_seen' => $agentAlive,
// Seit wann der Agent nur noch überspringt, und wer die Sperre
// hält. Beides null, solange er arbeitet.
@@ -393,6 +431,23 @@ 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 `[]`.
+ //
+ // Fix-Runde 1: dieses Feld existierte, aber kein Blade las es —
+ // ein gescheiterter Rettungsversuch war der Konsole unsichtbar,
+ // genau der Zustand, aus dem dieser Knopf befreien sollte.
+ // `finished_at` geht wie überall hier durch `timestamp()`, damit
+ // die View nicht selbst parsen muss; `error` durch dieselbe
+ // `errorMessage()`, die auch STATUS und LAST_RUN übersetzt —
+ // `rescue_failed` steht dafür jetzt in `update_error`.
+ 'rescue_last_run' => ($last = $this->readJson(self::RESCUE_LAST_RUN)) !== []
+ ? [
+ 'state' => $last['state'] ?? null,
+ 'finished_at' => $this->timestamp($last['finished_at'] ?? null),
+ 'error' => $this->errorMessage($last),
+ ]
+ : null,
];
}
@@ -590,6 +645,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.
*
@@ -905,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/app/Services/Deployment/WatchdogLog.php b/app/Services/Deployment/WatchdogLog.php
new file mode 100644
index 0000000..c3b270b
--- /dev/null
+++ b/app/Services/Deployment/WatchdogLog.php
@@ -0,0 +1,66 @@
+, stale: bool}|null
+ */
+ public function lastRun(): ?array
+ {
+ try {
+ $path = storage_path('app/'.self::FILE);
+
+ if (! File::exists($path)) {
+ return null;
+ }
+
+ $data = json_decode((string) File::get($path), true);
+
+ if (! is_array($data) || ! isset($data['at'])) {
+ return null;
+ }
+
+ $at = Carbon::parse((string) $data['at']);
+
+ return [
+ 'at' => $at,
+ 'outcome' => (string) ($data['outcome'] ?? 'idle'),
+ 'actions' => array_values(array_filter(
+ is_array($data['actions'] ?? null) ? $data['actions'] : [],
+ 'is_string'
+ )),
+ 'stale' => $at->lt(Carbon::now()->subMinutes(self::STALE_AFTER_MINUTES)),
+ ];
+ } catch (Throwable) {
+ // Dieselbe Haltung wie `UpdateChannel::readJson()`: die Konsole
+ // liest das bei jedem Seitenaufbau, und „ich weiß es nicht" ist
+ // ein brauchbarer Zustand — eine geworfene Ausnahme nicht.
+ return null;
+ }
+ }
+}
diff --git a/deploy/lib/release.sh b/deploy/lib/release.sh
index 25849bd..814ad63 100644
--- a/deploy/lib/release.sh
+++ b/deploy/lib/release.sh
@@ -216,3 +216,17 @@ json_escape() {
# raw; dropping them beats emitting a broken document.
printf '%s' "$s" | tr -d '\000-\037'
}
+
+# release_host_step_needs — welche Vertragsversion des root-eigenen Helfers
+# diese Fassung braucht.
+#
+# Sie stand zweimal im Repo: als `HOST_STEP_NEEDS=3` in update.sh und als
+# hartkodierte 3 im Agenten. Zwei Zahlen, die zusammenpassen müssen, laufen
+# irgendwann auseinander — und das Auseinanderlaufen zeigt sich erst auf einem
+# Wirt, dessen Helfer zu alt ist.
+#
+# Angehoben wird sie, wenn install-agent.sh dem Helfer einen Schritt beibringt,
+# auf den sich etwas anderes verlässt. Dann braucht JEDER Wirt einmal
+# `sudo bash deploy/install-agent.sh` — das ist Absicht und die Grenze, hinter
+# der root sitzt.
+release_host_step_needs() { printf '%s' 3; }
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/update-agent.sh b/deploy/update-agent.sh
index dba71e3..58e5c9d 100755
--- a/deploy/update-agent.sh
+++ b/deploy/update-agent.sh
@@ -176,7 +176,7 @@ release_stuck_lock() {
have="$("$step" contract 2>/dev/null || true)"
[[ "$have" =~ ^[0-9]+$ ]] || have=0
- if (( have < 3 )); then
+ if (( have < $(release_host_step_needs) )); then
write_unblock failed unblock_helper_old
return 0
fi
@@ -425,6 +425,22 @@ releases_json() {
printf '%s' "${out%,}"
}
+# Welchen Vertrag der Wirt-Helfer erfüllt — und welchen diese Fassung braucht.
+#
+# Gemeldet statt automatisiert: `sudoers` gewährt dem Dienstbenutzer genau
+# drei benannte Befehle, und etwas Root-Eigenes, das ungeprüft aus dem
+# beschreibbaren Checkout ausführt, gäbe jedem, der je an diesen Benutzer
+# kommt, Root auf dem Wirt. Die Grenze bleibt; die Konsole soll nur aufhören,
+# den Betreiber raten zu lassen.
+#
+# `|| true` und der Zahlentest: ein fehlender Helfer, ein Helfer ohne diesen
+# Schritt und ein Helfer, der etwas Unerwartetes druckt, sind alle „0" — und
+# keiner davon darf den Agenten unter `set -e` beenden, bevor er eine
+# Statusdatei schreibt.
+HOST_STEP_HAVE="$( { /usr/local/sbin/clupilot-host-step contract 2>/dev/null || true; } | head -1 )"
+[[ "$HOST_STEP_HAVE" =~ ^[0-9]+$ ]] || HOST_STEP_HAVE=0
+HOST_STEP_NEEDS="$(release_host_step_needs)"
+
write_status() {
local state="$1" error="${2-}"
cat > "$STATUS.tmp" < "$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 || true
@@ -101,6 +105,20 @@ 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=()
+
# ── 1. Fehlt ein Dienst? ─────────────────────────────────────────────────────
#
# `config --services` liest die Profile aus der .env mit, vpn-dns und
@@ -113,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
@@ -139,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
@@ -156,12 +197,26 @@ if printf '%s\n' "$ist" | grep -qx vpn-hub; then
if darf_eingreifen; then
say "wg0 steht nicht — ziehe den Tunnel hoch."
lange_frist docker compose exec -T vpn-hub wg-quick up wg0 >/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."
+ # 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
@@ -196,13 +251,70 @@ 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
+
+# ── Was die Konsole davon erfaehrt ───────────────────────────────────────────
+#
+# Der Waechter redete bisher NUR ins Journal — und das liegt auf dem Wirt,
+# waehrend die Konsole in einem Container laeuft. Sie sah ihn also gar nicht.
+# Am 4. August 2026 hat genau das die Fehlersuche gekostet: der Waechter hielt
+# die Sperre, der Agent kam nicht an die Arbeit, und die einzige Stelle, an der
+# das gestanden haette, war von der Konsole aus unerreichbar.
+#
+# Vier Ausgaenge, weil sie vier verschiedene Dinge bedeuten:
+# idle — nachgesehen, nichts zu tun. Der Normalfall.
+# 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.
+#
+# Atomar geschrieben: die Konsole liest diese Datei bei jedem Seitenaufbau,
+# und eine halbe JSON-Datei bricht die Seite in dem Moment, in dem jemand
+# nachsieht.
+ausgang=idle
+if [[ "$fehlgeschlagen" == true ]]; then
+ ausgang=tried
+elif [[ "$geheilt" == true ]]; then
+ ausgang=healed
+elif [[ "$SPERRE" == verwehrt ]]; then
+ ausgang=stood_down
+fi
+
+# Die Liste als JSON-Array. Anfuehrungszeichen, Backslashes und Umbrueche raus
+# — der einzige freie Text sind die eigenen Meldungen oben, aber verlassen
+# wird sich darauf nicht.
+eintraege=''
+for a in ${AKTIONEN+"${AKTIONEN[@]}"}; do
+ a="$(printf '%s' "$a" | tr -d '"\\' | tr '\n\r\t' ' ')"
+ eintraege+="\"$a\","
+done
+
+cat > "$STATE_DIR/watchdog-last-run.json.tmp" 2>/dev/null </dev/null || true
+{
+ "at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
+ "outcome": "$ausgang",
+ "actions": [${eintraege%,}]
+}
+EOF
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/docs/superpowers/plans/2026-08-04-wirt-in-die-konsole.md b/docs/superpowers/plans/2026-08-04-wirt-in-die-konsole.md
new file mode 100644
index 0000000..e035150
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-04-wirt-in-die-konsole.md
@@ -0,0 +1,825 @@
+# Wirt-Vorgänge in die Konsole — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Drei Vorgänge, für die heute die Kommandozeile auf dem Wirt nötig ist, werden aus der Konsole heraus sichtbar bzw. bedienbar — der Wächter, der Zustand des Wirt-Helfers, und die Tunnel-Rettung.
+
+**Architecture:** Alle drei folgen dem Muster, das Agent und Update-Kanal bereits benutzen: der Wirt schreibt Zustand als JSON nach `storage/app/deploy/` (Bind-Mount), die Konsole liest ihn. Neue Handlungen gehen durch den bestehenden Postkasten (`update-request.json` + `KIND_*`), nie über einen zweiten Weg.
+
+**Tech Stack:** Bash (`deploy/*.sh`), Laravel 13.8, Livewire 3, Pest, Tailwind v4.
+
+## Global Constraints
+
+- **Jede Datei, die der Wirt schreibt und die Konsole liest, wird ATOMAR geschrieben** (`.tmp` + `mv`/`rename`), und **beim Lesen fällt nichts um**, wenn sie fehlt, leer ist oder Unsinn enthält. In dieser Codebasis waren beide Punkte schon je ein Important-Befund. Vorbilder: `write_alive()` in `deploy/update-agent.sh`, `readJson()` in `UpdateChannel`.
+- **Kein zweiter Weg in eine Auslieferung oder einen Wirt-Vorgang.** Neue Handlungen gehen durch `UpdateChannel::submit()` mit eigener `KIND_*`, wie `KIND_CHECK`/`KIND_RESTART`/`KIND_ARCHIVE_KEY`.
+- **`deploy/update-agent.sh` und `deploy/watchdog.sh` laufen unter `set -e`-Regimen.** Eine Zuweisung aus einer Kommandoersetzung reicht deren Status weiter und beendet das Skript, BEVOR es Zustand schreibt — genau diese Ausfallart hat die Konsole schon zweimal eine nie endende Prüfung zeigen lassen. Jeder neue Ausdruck muss das überleben. `watchdog.sh` läuft unter `set -uo pipefail` (**ohne** `-e`), `update-agent.sh` unter `set -Eeuo pipefail`.
+- **Kein Aufruf nach draußen ohne Frist.** `timeout -k` wie im übrigen Wächter.
+- **Die Sicherheitsgrenze des Wirt-Helfers bleibt.** `sudoers` gewährt genau drei benannte Befehle. Es wird **nichts** gebaut, das root ungeprüft aus dem beschreibbaren Checkout ausführen lässt. Aufgabe ist zu **melden**, nicht zu automatisieren.
+- **R21** Operator-Guard, **R23** Bestätigung im Modal (kein `wire:confirm`, kein `confirm(`), **R18/R24** wie in CLAUDE.md.
+- **Sprachdateien:** jede Zeichenkette in `lang/de/` **und** `lang/en/`.
+- **Testlauf:** `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter=` — der `docker compose`-Aufruf **muss** aus `/home/nexxo/clupilot` kommen, nie aus dem Worktree (sonst „service app is not running"). `cd` hält zwischen Aufrufen nicht.
+- **Nie `git add -A`.** Eigene Pfade einzeln nennen, `git status` vorher prüfen — es laufen mehrere Sitzungen in diesem Arbeitsbaum.
+
+---
+
+## File Structure
+
+| Datei | Verantwortung |
+|---|---|
+| `deploy/watchdog.sh` | Schreibt am Ende jedes Laufs seinen Ausgang — auch den Rücktritt. |
+| `app/Services/Deployment/WatchdogLog.php` | **Neu.** Liest den Ausgang, beurteilt Frische. Eigene Klasse, weil `UpdateChannel` bereits über 900 Zeilen hat und der Wächter eine andere Sache ist als der Update-Kanal. |
+| `deploy/lib/release.sh` | Hält die gebrauchte Vertragsversion des Wirt-Helfers an **einer** Stelle. |
+| `deploy/update-agent.sh` | Meldet Vertragsversion (vorhanden/gebraucht); führt die Tunnel-Rettung aus. |
+| `app/Services/Deployment/UpdateChannel.php` | Neue Anfrageart Tunnel-Rettung, Helfer-Vertrag in `state()`. |
+| `app/Livewire/Admin/Settings.php` + Blade | Anzeige Wächter, Anzeige Helfer-Vertrag, Knopf Tunnel-Rettung. |
+
+---
+
+### Task 1: Der Wächter hinterlässt, was er getan hat
+
+**Files:**
+- Modify: `deploy/watchdog.sh`
+- Create: `app/Services/Deployment/WatchdogLog.php`
+- Test: `tests/Feature/WatchdogVisibilityTest.php` (neu)
+
+**Interfaces:**
+- Produces: Datei `storage/app/deploy/watchdog-last-run.json` mit
+ `{"at":"","outcome":"idle"|"healed"|"stood_down","actions":["…"]}`
+- Produces: `WatchdogLog::lastRun(): ?array` mit den Schlüsseln `at` (Carbon), `outcome` (string), `actions` (string[]), `stale` (bool — älter als 5 Minuten, also mehr als vier ausgefallene Takte)
+
+- [ ] **Step 1: Write the failing test**
+
+Neue Datei `tests/Feature/WatchdogVisibilityTest.php`:
+
+```php
+timeout(90)->env([
+ 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'),
+ 'STUB_UP_CALLED' => $dir.'/.stub-up-called',
+ ])->run(<</dev/null 2>&1 || true
+ pkill -f 'sleep 20' 2>/dev/null || true
+ BASH);
+
+ 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'));
+});
+
+it('records a run where there was nothing to do', function () {
+ $run = runWatchdog();
+
+ expect($run['outcome'])->toBe('idle')
+ ->and($run['actions'])->toBe([])
+ ->and($run['at'])->not->toBeEmpty();
+});
+
+it('records what it healed', function () {
+ // `app` fehlt in der Liste der laufenden Dienste — der Waechter startet
+ // die Dienste und muss das hinterlassen.
+ $run = runWatchdog(running: 'redis');
+
+ expect($run['outcome'])->toBe('healed')
+ ->and($run['actions'])->not->toBeEmpty();
+});
+
+it('records that it stood down because the lock was held', function () {
+ // DER Zustand, der bisher unsichtbar war. Ohne ihn sieht ein Waechter,
+ // der seit einer Stunde nicht eingreifen kann, genauso aus wie einer,
+ // der nichts zu tun hat.
+ $run = runWatchdog(running: 'redis', holdLock: true);
+
+ expect($run['outcome'])->toBe('stood_down');
+});
+
+it('reads nothing rather than falling over when the file is absent', function () {
+ File::ensureDirectoryExists(storage_path('app/deploy'));
+
+ expect(app(WatchdogLog::class)->lastRun())->toBeNull();
+});
+
+it('reads nothing rather than falling over when the file is rubbish', function () {
+ File::ensureDirectoryExists(storage_path('app/deploy'));
+ File::put(storage_path('app/deploy/watchdog-last-run.json'), 'kein json {');
+
+ expect(app(WatchdogLog::class)->lastRun())->toBeNull();
+});
+
+it('calls a run from long ago stale', function () {
+ // Ein toter Waechter muss als solcher lesbar sein. Bisher wuerde niemand
+ // es je erfahren.
+ File::ensureDirectoryExists(storage_path('app/deploy'));
+ File::put(storage_path('app/deploy/watchdog-last-run.json'), json_encode([
+ 'at' => now()->subMinutes(30)->utc()->format('Y-m-d\TH:i:s\Z'),
+ 'outcome' => 'idle',
+ 'actions' => [],
+ ]));
+
+ $run = app(WatchdogLog::class)->lastRun();
+
+ expect($run['stale'])->toBeTrue();
+});
+
+it('does not call a fresh run stale', function () {
+ 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',
+ 'actions' => [],
+ ]));
+
+ expect(app(WatchdogLog::class)->lastRun()['stale'])->toBeFalse();
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter=WatchdogVisibility`
+Expected: FAIL — weder die Datei noch die Klasse gibt es.
+
+- [ ] **Step 3: Implement — der Wächter schreibt seinen Ausgang**
+
+In `deploy/watchdog.sh`: eine Liste mitführen und am Ende schreiben.
+
+Direkt hinter `geheilt=false` einfügen:
+
+```bash
+# Was dieser Lauf getan hat, in der Reihenfolge. Die Konsole liest daraus
+# einen Satz; das Journal hat weiterhin die Langfassung.
+AKTIONEN=()
+```
+
+Die Funktion `say()` erweitern, damit jede Meldung zugleich in die Liste geht —
+**nicht** eine zweite Stelle, an der man daran denken muss:
+
+```bash
+say() {
+ if command -v logger >/dev/null 2>&1; then
+ logger -t "$LOG_TAG" -- "$*"
+ fi
+ printf '%s\n' "$*"
+ # Jede Meldung ist zugleich ein Eintrag fuer die Konsole. Eine zweite
+ # Stelle, an der man daran denken muesste, waere eine Stelle, an der es
+ # irgendwann vergessen wird.
+ AKTIONEN+=("$*")
+}
+```
+
+Am **Ende** der Datei, nach dem bestehenden `if [[ "$geheilt" == true ]]`-Block:
+
+```bash
+# ── Was die Konsole davon erfaehrt ───────────────────────────────────────────
+#
+# Der Waechter redete bisher NUR ins Journal — und das liegt auf dem Wirt,
+# waehrend die Konsole in einem Container laeuft. Sie sah ihn also gar nicht.
+# Am 4. August 2026 hat genau das die Fehlersuche gekostet: der Waechter hielt
+# 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:
+# idle — nachgesehen, nichts zu tun. Der Normalfall.
+# healed — eingegriffen. Was, steht in `actions`.
+# stood_down — nicht drangekommen, weil ein Update die Sperre hielt.
+# Betrieb, kein Fehler — aber es muss unterscheidbar sein.
+#
+# Atomar geschrieben: die Konsole liest diese Datei bei jedem Seitenaufbau,
+# und eine halbe JSON-Datei bricht die Seite in dem Moment, in dem jemand
+# nachsieht.
+ausgang=idle
+if [[ "$geheilt" == true ]]; then
+ ausgang=healed
+elif [[ "$SPERRE" == verwehrt ]]; then
+ ausgang=stood_down
+fi
+
+# Die Liste als JSON-Array. Anfuehrungszeichen, Backslashes und Umbrueche raus
+# — der einzige freie Text sind die eigenen Meldungen oben, aber verlassen
+# wird sich darauf nicht.
+eintraege=''
+for a in ${AKTIONEN+"${AKTIONEN[@]}"}; do
+ a="$(printf '%s' "$a" | tr -d '"\\' | tr '\n\r\t' ' ')"
+ eintraege+="\"$a\","
+done
+
+cat > "$STATE_DIR/watchdog-last-run.json.tmp" 2>/dev/null </dev/null || true
+{
+ "at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
+ "outcome": "$ausgang",
+ "actions": [${eintraege%,}]
+}
+EOF
+```
+
+**Achtung `set -u`:** `${AKTIONEN+"${AKTIONEN[@]}"}` statt `"${AKTIONEN[@]}"` — ein leeres Array gilt unter `set -u` in älteren bash als ungesetzt und bricht den Lauf.
+
+- [ ] **Step 4: Implement — die Konsole liest ihn**
+
+Neue Datei `app/Services/Deployment/WatchdogLog.php`:
+
+```php
+, stale: bool}|null
+ */
+ public function lastRun(): ?array
+ {
+ try {
+ $path = storage_path('app/'.self::FILE);
+
+ if (! File::exists($path)) {
+ return null;
+ }
+
+ $data = json_decode((string) File::get($path), true);
+
+ if (! is_array($data) || ! isset($data['at'])) {
+ return null;
+ }
+
+ $at = Carbon::parse((string) $data['at']);
+
+ return [
+ 'at' => $at,
+ 'outcome' => (string) ($data['outcome'] ?? 'idle'),
+ 'actions' => array_values(array_filter(
+ is_array($data['actions'] ?? null) ? $data['actions'] : [],
+ 'is_string'
+ )),
+ 'stale' => $at->lt(Carbon::now()->subMinutes(self::STALE_AFTER_MINUTES)),
+ ];
+ } catch (Throwable) {
+ // Dieselbe Haltung wie `UpdateChannel::readJson()`: die Konsole
+ // liest das bei jedem Seitenaufbau, und „ich weiß es nicht" ist
+ // ein brauchbarer Zustand — eine geworfene Ausnahme nicht.
+ return null;
+ }
+ }
+}
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter="WatchdogVisibility|WatchdogLockContention"`
+Expected: PASS — beide, denn `WatchdogLockContention` fährt denselben Wächter.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add deploy/watchdog.sh app/Services/Deployment/WatchdogLog.php tests/Feature/WatchdogVisibilityTest.php
+git commit -m "Der Waechter hinterlaesst, was er getan hat"
+```
+
+---
+
+### Task 2: Die gebrauchte Vertragsversion an einer Stelle, und ehrlich gemeldet
+
+**Files:**
+- Modify: `deploy/lib/release.sh` (neue Konstante/Funktion)
+- Modify: `deploy/update.sh:75` (die doppelte Stelle auflösen)
+- Modify: `deploy/update-agent.sh` (Vertrag melden; hartkodierte `3` auflösen)
+- Modify: `app/Services/Deployment/UpdateChannel.php` (`state()` durchreichen)
+- Test: `tests/Feature/HostStepContractTest.php` (neu)
+
+**Interfaces:**
+- Produces: `release_host_step_needs` in `deploy/lib/release.sh` → gibt die gebrauchte Vertragsversion aus (heute `3`)
+- Produces: `update-status.json` bekommt `host_step_contract` (int, `0` wenn der Helfer fehlt oder nicht antwortet) und `host_step_needs` (int)
+- Produces: `state()` bekommt `host_step_ok` (bool) und `host_step_have`/`host_step_needs` (int)
+
+- [ ] **Step 1: Write the failing test**
+
+Neue Datei `tests/Feature/HostStepContractTest.php`:
+
+```php
+toContain('release_host_step_needs')
+ ->and($update)->toContain('release_host_step_needs')
+ ->and($agent)->toContain('release_host_step_needs');
+
+ // Und keine nackte Zahl mehr an den beiden alten Stellen.
+ expect($update)->not->toContain('HOST_STEP_NEEDS=3')
+ ->and($agent)->not->toContain('(( have < 3 ))');
+});
+
+it('answers the needed contract version from the shell', function () {
+ $result = Process::path(base_path())->timeout(30)->run(
+ 'bash -c '.escapeshellarg('set -Eeuo pipefail; . deploy/lib/release.sh; release_host_step_needs')
+ );
+
+ expect($result->exitCode())->toBe(0)
+ ->and(trim($result->output()))->toMatch('/^[0-9]+$/');
+});
+
+it('reports the helper as not ok when the host has an older one', function () {
+ File::ensureDirectoryExists(storage_path('app/deploy'));
+ File::put(storage_path('app/deploy/update-status.json'), json_encode([
+ 'state' => 'idle',
+ 'host_step_contract' => 2,
+ 'host_step_needs' => 3,
+ ]));
+
+ $state = app(UpdateChannel::class)->state();
+
+ expect($state['host_step_ok'])->toBeFalse()
+ ->and($state['host_step_have'])->toBe(2)
+ ->and($state['host_step_needs'])->toBe(3);
+});
+
+it('reports the helper as ok when it is current', function () {
+ File::ensureDirectoryExists(storage_path('app/deploy'));
+ File::put(storage_path('app/deploy/update-status.json'), json_encode([
+ 'state' => 'idle',
+ 'host_step_contract' => 3,
+ 'host_step_needs' => 3,
+ ]));
+
+ expect(app(UpdateChannel::class)->state()['host_step_ok'])->toBeTrue();
+});
+
+it('does not cry wolf when the agent has not reported yet', function () {
+ // Ein Wirt, dessen Agent die Zahlen noch nie gemeldet hat (alte Fassung,
+ // erster Lauf), darf nicht als kaputt dastehen. „Ich weiß es nicht" ist
+ // nicht dasselbe wie „zu alt".
+ File::ensureDirectoryExists(storage_path('app/deploy'));
+ File::put(storage_path('app/deploy/update-status.json'), json_encode(['state' => 'idle']));
+
+ expect(app(UpdateChannel::class)->state()['host_step_ok'])->toBeTrue();
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter=HostStepContract`
+Expected: FAIL
+
+- [ ] **Step 3: Implement — eine Stelle für die Zahl**
+
+Ans Ende von `deploy/lib/release.sh`:
+
+```bash
+# release_host_step_needs — welche Vertragsversion des root-eigenen Helfers
+# diese Fassung braucht.
+#
+# Sie stand zweimal im Repo: als `HOST_STEP_NEEDS=3` in update.sh und als
+# hartkodierte 3 im Agenten. Zwei Zahlen, die zusammenpassen müssen, laufen
+# irgendwann auseinander — und das Auseinanderlaufen zeigt sich erst auf einem
+# Wirt, dessen Helfer zu alt ist.
+#
+# Angehoben wird sie, wenn install-agent.sh dem Helfer einen Schritt beibringt,
+# auf den sich etwas anderes verlässt. Dann braucht JEDER Wirt einmal
+# `sudo bash deploy/install-agent.sh` — das ist Absicht und die Grenze, hinter
+# der root sitzt.
+release_host_step_needs() { printf '%s' 3; }
+```
+
+In `deploy/update.sh` die Zeile `HOST_STEP_NEEDS=3` ersetzen durch:
+
+```bash
+HOST_STEP_NEEDS="$(release_host_step_needs)"
+```
+
+(`deploy/lib/release.sh` wird dort bereits geladen — nachprüfen und, falls nicht, den Aufruf hinter das vorhandene `.`-Einbinden setzen.)
+
+In `deploy/update-agent.sh`, in `release_stuck_lock()`, `if (( have < 3 ))` ersetzen durch:
+
+```bash
+ if (( have < $(release_host_step_needs) )); then
+```
+
+- [ ] **Step 4: Implement — der Agent meldet den Vertrag**
+
+In `deploy/update-agent.sh`, vor `write_status()`, die Werte ermitteln. **Wichtig unter `set -e`:** ein fehlender oder fehlschlagender Helfer darf den Agenten nicht beenden.
+
+```bash
+# Welchen Vertrag der Wirt-Helfer erfüllt — und welchen diese Fassung braucht.
+#
+# Gemeldet statt automatisiert: `sudoers` gewährt dem Dienstbenutzer genau
+# drei benannte Befehle, und etwas Root-Eigenes, das ungeprüft aus dem
+# beschreibbaren Checkout ausführt, gäbe jedem, der je an diesen Benutzer
+# kommt, Root auf dem Wirt. Die Grenze bleibt; die Konsole soll nur aufhören,
+# den Betreiber raten zu lassen.
+#
+# `|| true` und der Zahlentest: ein fehlender Helfer, ein Helfer ohne diesen
+# Schritt und ein Helfer, der etwas Unerwartetes druckt, sind alle „0" — und
+# keiner davon darf den Agenten unter `set -e` beenden, bevor er eine
+# Statusdatei schreibt.
+HOST_STEP_HAVE="$( { /usr/local/sbin/clupilot-host-step contract 2>/dev/null || true; } | head -1 )"
+[[ "$HOST_STEP_HAVE" =~ ^[0-9]+$ ]] || HOST_STEP_HAVE=0
+HOST_STEP_NEEDS="$(release_host_step_needs)"
+```
+
+In `write_status()` hinter der `"behind"`-Zeile ergänzen:
+
+```bash
+ "host_step_contract": ${HOST_STEP_HAVE:-0},
+ "host_step_needs": ${HOST_STEP_NEEDS:-0},
+```
+
+- [ ] **Step 5: Implement — die Konsole liest ihn**
+
+In `UpdateChannel::state()`, im `return`-Array:
+
+```php
+ // Der root-eigene Helfer auf dem Wirt. Fehlt die Meldung ganz
+ // (alte Agentenfassung, allererster Lauf), gilt er als in
+ // Ordnung: „ich weiß es nicht" ist nicht „zu alt", und ein
+ // Warnkasten, der auf jedem frisch aufgesetzten Wirt steht, wird
+ // nach zwei Tagen nicht mehr gelesen.
+ 'host_step_have' => isset($status['host_step_contract'])
+ ? (int) $status['host_step_contract']
+ : null,
+ 'host_step_needs' => isset($status['host_step_needs'])
+ ? (int) $status['host_step_needs']
+ : null,
+ 'host_step_ok' => ! isset($status['host_step_contract'], $status['host_step_needs'])
+ || (int) $status['host_step_contract'] >= (int) $status['host_step_needs'],
+```
+
+- [ ] **Step 6: Run tests, then commit**
+
+Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter="HostStepContract|ReleaseComparison|ReleaseCeiling|UpdateLockRelease"`
+Expected: PASS
+
+```bash
+git add deploy/lib/release.sh deploy/update.sh deploy/update-agent.sh app/Services/Deployment/UpdateChannel.php tests/Feature/HostStepContractTest.php
+git commit -m "Die gebrauchte Vertragsversion steht an einer Stelle und wird gemeldet"
+```
+
+---
+
+### Task 3: Tunnel-Rettung aus der Konsole, und die drei Anzeigen
+
+**Files:**
+- Modify: `app/Services/Deployment/UpdateChannel.php` (neue Anfrageart)
+- Modify: `deploy/update-agent.sh` (Anfrageart ausführen)
+- Modify: `app/Livewire/Admin/Settings.php`
+- Create: `app/Livewire/Admin/ConfirmRescueTunnel.php` + Blade
+- Modify: `resources/views/livewire/admin/settings.blade.php`
+- Modify: `lang/de/admin_settings.php`, `lang/en/admin_settings.php`
+- Test: `tests/Feature/RescueTunnelTest.php` (neu)
+
+**Interfaces:**
+- Consumes: `WatchdogLog::lastRun()` (Task 1), `state()['host_step_ok'|'host_step_have'|'host_step_needs']` (Task 2)
+- Produces: `UpdateChannel::KIND_RESCUE_TUNNEL = 'rescue-tunnel'`, `UpdateChannel::requestRescueTunnel(string $by): bool`
+- Produces: `storage/app/deploy/rescue-last-run.json` `{"state":"ok"|"failed","finished_at":"…","error":"…"}` und `storage/app/deploy/rescue-last-run.log`
+- Produces: `state()['rescue_last_run']`, `Settings::rescueTunnel()`
+
+- [ ] **Step 1: Write the failing test**
+
+Neue Datei `tests/Feature/RescueTunnelTest.php`:
+
+```php
+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');
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter=RescueTunnel`
+Expected: FAIL
+
+- [ ] **Step 3: Implement — Anfrageart im Kanal**
+
+In `UpdateChannel`, bei den übrigen `KIND_*`:
+
+```php
+ /**
+ * 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';
+```
+
+Und die Methode, neben `requestCheck()`:
+
+```php
+ public function requestRescueTunnel(string $by): bool
+ {
+ return $this->submit($by, self::KIND_RESCUE_TUNNEL);
+ }
+```
+
+In `state()` im `return`-Array:
+
+```php
+ // `readJson` fängt kaputtes JSON bereits ab und liefert `[]`.
+ 'rescue_last_run' => ($last = $this->readJson(self::RESCUE_LAST_RUN)) !== []
+ ? $last
+ : null,
+```
+
+- [ ] **Step 4: Implement — der Agent führt sie aus**
+
+In `deploy/update-agent.sh`, dort wo die übrigen Anfragearten unterschieden werden (bei `KIND`/`kind`), einen Zweig ergänzen — **nach** dem Muster der bestehenden Arten, mit Frist und ohne den Lauf sterben zu lassen:
+
+```bash
+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" <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'));
+ }
+```
+
+In `render()` den Wächter-Zustand mitgeben:
+
+```php
+ $watchdog = app(WatchdogLog::class)->lastRun();
+```
+
+und in die View-Daten aufnehmen (`'watchdog' => $watchdog`).
+
+`ConfirmRescueTunnel.php` und sein Blade nach dem Muster von `ConfirmReleaseUpdateLock` — **den bestehenden lesen und die Bauform übernehmen**, nicht erfinden.
+
+Im Blade, im selben Abschnitt wie die übrigen Update-Anzeigen:
+
+```blade
+{{-- 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
+```
+
+**R19 beachten:** `->local()` vor jeder Zeitausgabe. `diffForHumans()` ist davon ausgenommen (relativ), aber `->local()` schadet nicht und hält die Regel sichtbar.
+
+Der Knopf für die Tunnel-Rettung kommt in die **bestehende** Knopfleiste (siehe wie das Festnageln dort eingefügt wurde), mit `$dispatch('openModal', { component: 'admin.confirm-rescue-tunnel' })`.
+
+Sprachschlüssel (de **und** en), sinngemäß:
+`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." ·
+`host_step_old` „Der Wirt-Helfer erfüllt Vertrag :have, gebraucht wird :needs. Einmalig auf diesem Wirt ausführen:" ·
+`rescue_requested` „Tunnel-Rettung angefordert." ·
+plus Titel/Text/Knöpfe für das Bestätigungs-Modal.
+
+- [ ] **Step 6: Ganze Suite, dann committen**
+
+Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test`
+Expected: PASS, vollständig. Achte auf `ConfirmInModalTest`, `ModalHeightTest`, `IconLayoutTest`, `DisplayTimezoneTest`, `TranslationParityTest`.
+
+```bash
+git add app/Services/Deployment/UpdateChannel.php deploy/update-agent.sh \
+ app/Livewire/Admin/Settings.php app/Livewire/Admin/ConfirmRescueTunnel.php \
+ resources/views/livewire/admin/confirm-rescue-tunnel.blade.php \
+ resources/views/livewire/admin/settings.blade.php \
+ lang/de/admin_settings.php lang/en/admin_settings.php \
+ tests/Feature/RescueTunnelTest.php
+git commit -m "Tunnel-Rettung aus der Konsole, Waechter und Wirt-Helfer sichtbar"
+```
diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php
index 541f2d8..d944e49 100644
--- a/lang/de/admin_settings.php
+++ b/lang/de/admin_settings.php
@@ -139,6 +139,28 @@ 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',
+ // 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.',
+
+ // ── 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 +174,28 @@ 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',
+ // 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
// (App\Services\Deployment\UpdateChannel::setCeiling()). Bestätigt wird
@@ -181,6 +225,7 @@ return [
'detached_no_release' => 'Der Checkout hängt an keinem Branch und ist auf keine Version gepinnt.',
'update_failed' => 'Die Aktualisierung ist fehlgeschlagen (Code :code). Siehe Protokoll.',
'restart_failed' => 'Der Neustart der Dienste ist fehlgeschlagen. Bitte manuell ausführen: docker compose restart queue scheduler reverb.',
+ 'rescue_failed' => 'Die Tunnel-Rettung ist fehlgeschlagen. Protokoll auf dem Wirt: storage/app/deploy/rescue-last-run.log.',
// Der Wirt kennt den Schritt noch nicht. Ein Knopf, der nichts tut und
// nichts sagt, schickt den Betreiber genau dorthin zurück, wo er ohne
// die Konsole schon war.
diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php
index ada1cd6..f64c155 100644
--- a/lang/en/admin_settings.php
+++ b/lang/en/admin_settings.php
@@ -136,6 +136,28 @@ 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',
+ // 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.',
+
+ // ── 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 +171,28 @@ 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',
+ // 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
// window (App\Services\Deployment\UpdateChannel::setCeiling()). Only
@@ -178,6 +222,7 @@ return [
'detached_no_release' => 'The checkout is on no branch and pinned to no release.',
'update_failed' => 'The update failed (code :code). See the log.',
'restart_failed' => 'Restarting the services failed. Please run manually: docker compose restart queue scheduler reverb.',
+ 'rescue_failed' => 'The tunnel rescue failed. Log on the host: storage/app/deploy/rescue-last-run.log.',
// The host does not know the step yet. A button that does nothing and
// says nothing sends the operator right back where they were without
// the console.
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 bf60860..fa10c8b 100644
--- a/resources/views/livewire/admin/settings.blade.php
+++ b/resources/views/livewire/admin/settings.blade.php
@@ -117,6 +117,46 @@
{{ $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)
+ {{-- 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['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()]) }}
+ @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
@@ -185,6 +225,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
@@ -394,6 +444,45 @@
@endif
+ {{-- Fix-Runde 1 (Task 3): das Ergebnis der Tunnel-Rettung. Der Agent
+ schrieb `rescue-last-run.json` von Anfang an — nur las kein Blade
+ es. Ein Betreiber, der „Tunnel retten" drueckt und dessen Versuch
+ auf dem Wirt scheitert (oder in die Frist laeuft), sah danach
+ nichts: kein Fehler, kein Zeitstempel, kein Hinweis aufs
+ 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' => $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'])
+ · {{ $update['rescue_last_run']['error'] }}
+ @endif
+
+ @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/HostStepContractTest.php b/tests/Feature/HostStepContractTest.php
new file mode 100644
index 0000000..9826ab7
--- /dev/null
+++ b/tests/Feature/HostStepContractTest.php
@@ -0,0 +1,85 @@
+toContain('release_host_step_needs')
+ ->and($update)->toContain('release_host_step_needs')
+ ->and($agent)->toContain('release_host_step_needs');
+
+ // Und keine nackte Zahl mehr an den beiden alten Stellen.
+ expect($update)->not->toContain('HOST_STEP_NEEDS=3')
+ ->and($agent)->not->toContain('(( have < 3 ))');
+});
+
+it('answers the needed contract version from the shell', function () {
+ $result = Process::path(base_path())->timeout(30)->run(
+ 'bash -c '.escapeshellarg('set -Eeuo pipefail; . deploy/lib/release.sh; release_host_step_needs')
+ );
+
+ expect($result->exitCode())->toBe(0)
+ ->and(trim($result->output()))->toMatch('/^[0-9]+$/');
+});
+
+it('reports the helper as not ok when the host has an older one', function () {
+ File::ensureDirectoryExists(storage_path('app/deploy'));
+ File::put(storage_path('app/deploy/update-status.json'), json_encode([
+ 'state' => 'idle',
+ 'host_step_contract' => 2,
+ 'host_step_needs' => 3,
+ ]));
+
+ $state = app(UpdateChannel::class)->state();
+
+ expect($state['host_step_ok'])->toBeFalse()
+ ->and($state['host_step_have'])->toBe(2)
+ ->and($state['host_step_needs'])->toBe(3);
+});
+
+it('reports the helper as ok when it is current', function () {
+ File::ensureDirectoryExists(storage_path('app/deploy'));
+ File::put(storage_path('app/deploy/update-status.json'), json_encode([
+ 'state' => 'idle',
+ 'host_step_contract' => 3,
+ 'host_step_needs' => 3,
+ ]));
+
+ expect(app(UpdateChannel::class)->state()['host_step_ok'])->toBeTrue();
+});
+
+it('does not cry wolf when the agent has not reported yet', function () {
+ // Ein Wirt, dessen Agent die Zahlen noch nie gemeldet hat (alte Fassung,
+ // erster Lauf), darf nicht als kaputt dastehen. „Ich weiß es nicht" ist
+ // nicht dasselbe wie „zu alt".
+ File::ensureDirectoryExists(storage_path('app/deploy'));
+ File::put(storage_path('app/deploy/update-status.json'), json_encode(['state' => 'idle']));
+
+ expect(app(UpdateChannel::class)->state()['host_step_ok'])->toBeTrue();
+});
diff --git a/tests/Feature/HostStepTest.php b/tests/Feature/HostStepTest.php
index 5e165c8..ed7019d 100644
--- a/tests/Feature/HostStepTest.php
+++ b/tests/Feature/HostStepTest.php
@@ -130,9 +130,31 @@ it('reports the contract version the updater compares against', function () {
// failure mode this number exists for; if update.sh asked for a version the
// current installer never writes, every up-to-date server would be told to
// run the installer again forever.
- $needs = Str::between(file_get_contents(base_path('deploy/update.sh')), 'HOST_STEP_NEEDS=', "\n");
+ //
+ // Compared at RUNTIME against deploy/lib/release.sh's
+ // release_host_step_needs(), not by pulling the literal `HOST_STEP_NEEDS=`
+ // line out of update.sh: since Task 2 update.sh no longer carries the bare
+ // number itself, it asks that shell function for it —
+ // `HOST_STEP_NEEDS="$(release_host_step_needs)"`. Extracting the text
+ // between `HOST_STEP_NEEDS=` and the newline would hand back the command
+ // substitution string, not a number, and this test would silently compare
+ // "3" against "0" forever without ever catching the two halves drifting
+ // apart. The left side (CONTRACT) and the right side (release_host_step_needs)
+ // come from two different files — install-agent.sh and deploy/lib/release.sh
+ // — so raising one without the other still turns this test red.
+ $needsProcess = Process::fromShellCommandline(
+ 'bash -c '.escapeshellarg(
+ 'set -Eeuo pipefail; . '.escapeshellarg(base_path('deploy/lib/release.sh')).'; release_host_step_needs'
+ )
+ );
+ $needsProcess->run();
- expect((int) trim($process->getOutput()))->toBe((int) trim($needs));
+ expect($needsProcess->getExitCode())->toBe(0);
+
+ $needs = trim($needsProcess->getOutput());
+
+ expect($needs)->toMatch('/^\d+$/')
+ ->and((int) trim($process->getOutput()))->toBe((int) $needs);
});
it('grants one command line, not a script the service account can rewrite', function () {
diff --git a/tests/Feature/RescueTunnelTest.php b/tests/Feature/RescueTunnelTest.php
new file mode 100644
index 0000000..0cfbe86
--- /dev/null
+++ b/tests/Feature/RescueTunnelTest.php
@@ -0,0 +1,291 @@
+ $peers Zeilen für "wg show wg0 peers" (leer = kein Zugang)
+ * @param array $transfer Zeilen für "wg show wg0 transfer" ("pubkey empfangen gesendet")
+ */
+function rescueTunnelStub(array $peers = ['PEER1'], array $transfer = ['PEER1 0 999'], bool $sudoOk = true, bool $ownContainer = true): string
+{
+ $dir = storage_path('app/deploy');
+ File::ensureDirectoryExists($dir);
+
+ $stub = $dir.'/stub-rescue';
+ File::ensureDirectoryExists($stub);
+
+ $peersOut = implode('\n', $peers);
+ $transferOut = implode('\n', $transfer);
+ $cmdlineOut = $ownContainer ? 'vpn-hub' : 'some-other-process';
+
+ File::put($stub.'/docker', <<requestRescueTunnel('chef@example.com'))->toBeTrue();
+
+ $request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true);
+
+ expect($request['kind'])->toBe('rescue-tunnel')
+ ->and($request['requested_by'])->toBe('chef@example.com');
+});
+
+it('takes only one request at a time', function () {
+ $channel = app(UpdateChannel::class);
+
+ expect($channel->requestRescueTunnel('chef@example.com'))->toBeTrue()
+ ->and($channel->requestRescueTunnel('chef@example.com'))->toBeFalse();
+});
+
+it('rescues the tunnel from the console', function () {
+ $owner = Operator::factory()->role('Owner')->create();
+
+ Livewire::actingAs($owner, 'operator')
+ ->test(Settings::class)
+ ->call('rescueTunnel');
+
+ expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeTrue();
+});
+
+it('refuses to rescue without site.manage', function () {
+ // Eine Livewire-Aktion ist ein oeffentlicher Endpunkt.
+ $staff = Operator::factory()->create();
+
+ Livewire::actingAs($staff, 'operator')
+ ->test(Settings::class)
+ ->call('rescueTunnel')
+ ->assertForbidden();
+});
+
+it('reads the outcome without falling over when it is rubbish', function () {
+ File::put(storage_path('app/deploy/rescue-last-run.json'), 'kein json {');
+
+ expect(app(UpdateChannel::class)->state()['rescue_last_run'])->toBeNull();
+});
+
+it('agent knows the kind', function () {
+ // Der Agent muss die Art kennen, sonst liegt die Anfrage bis zum Ablauf
+ // im Postkasten und niemand erfaehrt warum.
+ expect(File::get(base_path('deploy/update-agent.sh')))
+ ->toContain('rescue-tunnel')
+ ->toContain('rescue-tunnel.sh');
+});
+
+it('agent passes the rescue error through instead of swallowing it', function () {
+ // Fix-Runde 1: `write_status idle` (ohne zweites Argument) verschluckte
+ // RESCUE_ERROR. Der Zweig muss den Fehler weiterreichen, wie proxy-hosts
+ // es daneben schon tut.
+ expect(File::get(base_path('deploy/update-agent.sh')))
+ ->toContain('write_status idle "$RESCUE_ERROR"');
+});
+
+it('shapes the last rescue outcome for the view, with a local timestamp and a translated error', function () {
+ // Fix-Runde 1: das Feld existierte, aber nichts las es. Hier der
+ // Nachweis auf Datenebene, dass die Form stimmt, die das Blade braucht —
+ // `finished_at` als Carbon (R19: die View parst nicht selbst) und
+ // `error` schon uebersetzt (dieselbe errorMessage(), die auch STATUS und
+ // LAST_RUN uebersetzt).
+ File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([
+ 'state' => 'failed',
+ 'finished_at' => now()->utc()->toIso8601String(),
+ 'error' => 'rescue_failed',
+ ]));
+
+ $run = app(UpdateChannel::class)->state()['rescue_last_run'];
+
+ expect($run['state'])->toBe('failed')
+ ->and($run['finished_at'])->toBeInstanceOf(\Illuminate\Support\Carbon::class)
+ ->and($run['error'])->toBe(__('admin_settings.update_error.rescue_failed'));
+});
+
+it('shows a failed rescue attempt in the console, instead of silence', function () {
+ // Das eigentliche Befund-Szenario: Betreiber drueckt "Tunnel retten",
+ // bekommt "angefordert", der Versuch scheitert auf dem Wirt. Vorher
+ // zeigte die Konsole danach exakt nichts.
+ File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([
+ 'state' => 'failed',
+ 'finished_at' => now()->utc()->toIso8601String(),
+ 'error' => 'rescue_failed',
+ ]));
+ $owner = Operator::factory()->role('Owner')->create();
+
+ Livewire::actingAs($owner, 'operator')
+ ->test(Settings::class)
+ ->assertSee(__('admin_settings.update_state.failed'))
+ ->assertSee(__('admin_settings.update_error.rescue_failed'), false)
+ ->assertSeeHtml('text-danger');
+});
+
+it('shows a successful rescue attempt too', function () {
+ File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([
+ 'state' => 'ok',
+ 'finished_at' => now()->utc()->toIso8601String(),
+ 'error' => '',
+ ]));
+ $owner = Operator::factory()->role('Owner')->create();
+
+ Livewire::actingAs($owner, 'operator')
+ ->test(Settings::class)
+ ->assertSee(__('admin_settings.rescue_state_ok'));
+});
+
+it('does not call a rescue attempt "succeeded" — a run that completed is not proof the tunnel works', function () {
+ // Review-Runde 2 (C1 Teil 3): update_state.succeeded ("erfolgreich")
+ // behauptet mehr, als ein durchgelaufenes rescue-tunnel.sh weiss. Dieser
+ // Test faellt auf den Vorzustand rot: dort las settings.blade.php das
+ // 'ok'-Ergebnis noch als admin_settings.update_state.succeeded.
+ File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([
+ 'state' => 'ok',
+ 'finished_at' => now()->utc()->toIso8601String(),
+ 'error' => '',
+ ]));
+ $owner = Operator::factory()->role('Owner')->create();
+
+ Livewire::actingAs($owner, 'operator')
+ ->test(Settings::class)
+ ->assertDontSee(__('admin_settings.update_state.succeeded'));
+});
+
+it('shows the rescue log tail in the console, the same way the update log already does', function () {
+ // Review-Runde 2 (C1 Teil 2): der Log-Auszug gehoert in die Konsole,
+ // uebernimmt das Muster von UpdateChannel::lastLog() / 'update_log'
+ // statt eines neu erfundenen.
+ File::put(storage_path('app/deploy/rescue-last-run.log'), "Zeile eins\nACHTUNG: wg0 liess sich nicht hochziehen.\n");
+ $owner = Operator::factory()->role('Owner')->create();
+
+ Livewire::actingAs($owner, 'operator')
+ ->test(Settings::class)
+ ->assertSee(__('admin_settings.rescue_log'))
+ ->assertSee('ACHTUNG: wg0 liess sich nicht hochziehen', false);
+});
+
+it('shows nothing for the rescue log when no rescue has ever run', function () {
+ $owner = Operator::factory()->role('Owner')->create();
+
+ Livewire::actingAs($owner, 'operator')
+ ->test(Settings::class)
+ ->assertDontSee(__('admin_settings.rescue_log'));
+});
+
+// ── C1 Teil 1: das Skript selbst muss Misserfolg als Misserfolg melden ──────
+
+it('exits with a problem when not a single peer is loaded', function () {
+ // Der zweite der beiden im Review genannten Stellen: "Kein einziger
+ // Zugang geladen" endete bisher trotzdem auf Exit 0.
+ $stub = rescueTunnelStub(peers: [], transfer: []);
+
+ $result = Process::path(base_path())->timeout(60)->env([
+ 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'),
+ ])->run('bash deploy/rescue-tunnel.sh');
+
+ expect($result->exitCode())->not->toBe(0);
+});
+
+it('exits with a problem when stale conntrack entries cannot be cleared for lack of root', function () {
+ // DAS Fehlerszenario aus dem Review: 1 von 1 Zugaengen stumm, `sudo -n
+ // conntrack -D` scheitert (kein sudoers-Eintrag — laut Runbook auf einem
+ // frischen Wirt der Normalfall). Bisher: nur `bad(...)`, Exit 0.
+ $stub = rescueTunnelStub(peers: ['PEER1'], transfer: ['PEER1 0 999'], sudoOk: false);
+
+ $result = Process::path(base_path())->timeout(60)->env([
+ 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'),
+ ])->run('bash deploy/rescue-tunnel.sh');
+
+ expect($result->exitCode())->not->toBe(0)
+ ->and($result->output())->toContain('Dafür fehlt mir Root');
+});
+
+it('still exits clean when everything actually lines up, including on a host without its own tunnel container', function () {
+ // Gegenprobe zur Einteilung aus C1 Teil 1: die Meldung ueber einen noch
+ // fehlenden eigenen Tunnel-Container (Zeile 58/59) ist eine reine
+ // Versionsauskunft, kein Fehlschlag DIESES Laufs — sie darf den
+ // Rueckgabewert nicht anfassen, sonst meldete jeder Lauf auf einem
+ // aelteren Wirt "fehlgeschlagen", obwohl der Tunnel tadellos steht.
+ $stub = rescueTunnelStub(peers: ['PEER1'], transfer: ['PEER1 100 999'], sudoOk: true, ownContainer: false);
+
+ $result = Process::path(base_path())->timeout(60)->env([
+ 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'),
+ ])->run('bash deploy/rescue-tunnel.sh');
+
+ expect($result->exitCode())->toBe(0)
+ ->and($result->output())->toContain('eigenen Tunnel-Container noch nicht');
+});
+
+it('marks a rescue attempt failed end-to-end when conntrack cannot be cleared, and the console does not call it "succeeded"', function () {
+ // C1, der ganze Weg durch: das ECHTE deploy/update-agent.sh ruft das
+ // ECHTE deploy/rescue-tunnel.sh, genau wie auf dem Wirt. Der Nachweis
+ // wird — wie gefordert — im gerenderten HTML gefuehrt, nicht nur auf
+ // Datenebene: genau dort ist der vorige Fix schon einmal durchgerutscht.
+ $dir = storage_path('app/deploy');
+ $stub = rescueTunnelStub(peers: ['PEER1'], transfer: ['PEER1 0 999'], sudoOk: false);
+
+ // `git` scheitert sofort — dieser Tick soll die bereits im Postkasten
+ // liegende Bitte abarbeiten, kein echtes `git fetch` versuchen.
+ File::put($stub.'/git', "#!/bin/sh\nexit 1\n");
+ Process::run("chmod +x {$stub}/git");
+
+ $owner = Operator::factory()->role('Owner')->create();
+ app(UpdateChannel::class)->requestRescueTunnel($owner->email);
+
+ $result = Process::path(base_path())->timeout(90)->env([
+ 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'),
+ ])->run('bash deploy/update-agent.sh >/dev/null 2>&1 || true');
+
+ expect($result->successful())->toBeTrue($result->errorOutput());
+
+ $run = json_decode(File::get($dir.'/rescue-last-run.json'), true);
+ expect($run['state'])->toBe('failed');
+
+ Livewire::actingAs($owner, 'operator')
+ ->test(Settings::class)
+ ->assertSee(__('admin_settings.update_state.failed'))
+ ->assertDontSee(__('admin_settings.update_state.succeeded'))
+ ->assertSee(__('admin_settings.rescue_log'));
+});
diff --git a/tests/Feature/UpdateLockReleaseOnTheHostTest.php b/tests/Feature/UpdateLockReleaseOnTheHostTest.php
index 67d05ea..e12c0ad 100644
--- a/tests/Feature/UpdateLockReleaseOnTheHostTest.php
+++ b/tests/Feature/UpdateLockReleaseOnTheHostTest.php
@@ -223,5 +223,9 @@ it('grants the new step in sudoers and raises the contract for it', function ()
expect($installer)->toContain('release-update-lock)')
->and($installer)->toContain('$HOST_STEP release-update-lock')
->and($installer)->toContain('CONTRACT=3')
- ->and($updater)->toContain('HOST_STEP_NEEDS=3');
+ // Die gebrauchte Vertragsversion steht seit deploy/lib/release.sh nur
+ // noch an einer Stelle (release_host_step_needs); update.sh fragt sie
+ // ab, statt sie ein zweites Mal als Zahl mitzuführen. Siehe
+ // tests/Feature/HostStepContractTest.php.
+ ->and($updater)->toContain('release_host_step_needs');
});
diff --git a/tests/Feature/WatchdogVisibilityTest.php b/tests/Feature/WatchdogVisibilityTest.php
new file mode 100644
index 0000000..4deff39
--- /dev/null
+++ b/tests/Feature/WatchdogVisibilityTest.php
@@ -0,0 +1,334 @@
+timeout(90)->env([
+ 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'),
+ 'STUB_UP_CALLED' => $dir.'/.stub-up-called',
+ ])->run(<</dev/null 2>&1 || true
+ pkill -f 'sleep 20' 2>/dev/null || true
+ BASH);
+
+ expect($result->successful())->toBeTrue($result->errorOutput());
+
+ 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);
+}
+
+/**
+ * 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'));
+});
+
+it('records a run where there was nothing to do', function () {
+ $run = runWatchdog();
+
+ expect($run['outcome'])->toBe('idle')
+ ->and($run['actions'])->toBe([])
+ ->and($run['at'])->not->toBeEmpty();
+});
+
+it('records what it healed', function () {
+ // `app` fehlt in der Liste der laufenden Dienste — der Waechter startet
+ // die Dienste und muss das hinterlassen.
+ $run = runWatchdog(running: 'redis');
+
+ expect($run['outcome'])->toBe('healed')
+ ->and($run['actions'])->not->toBeEmpty();
+});
+
+it('records that it stood down because the lock was held', function () {
+ // DER Zustand, der bisher unsichtbar war. Ohne ihn sieht ein Waechter,
+ // der seit einer Stunde nicht eingreifen kann, genauso aus wie einer,
+ // der nichts zu tun hat.
+ $run = runWatchdog(running: 'redis', holdLock: true);
+
+ 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('shows the operator that an intervention failed, instead of "nothing to do"', function () {
+ // Fix-Runde 1 (Codex/Reviewer): auf Datenebene war der Fall (outcome
+ // idle, actions nicht leer) schon belegt — hier der Nachweis, dass er
+ // 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' => 'tried',
+ 'actions' => [
+ '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('ACHTUNG: wg0 liess sich nicht hochziehen', false)
+ ->assertSeeHtml('text-warning')
+ // Das war der Befund: watchdog_idle endet auf genau diesem Satzstueck,
+ // und es war der einzige Zweig, der auf einen outcome von "idle" ohne
+ // weitere Pruefung durchfiel.
+ ->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'));
+
+ expect(app(WatchdogLog::class)->lastRun())->toBeNull();
+});
+
+it('reads nothing rather than falling over when the file is rubbish', function () {
+ File::ensureDirectoryExists(storage_path('app/deploy'));
+ File::put(storage_path('app/deploy/watchdog-last-run.json'), 'kein json {');
+
+ expect(app(WatchdogLog::class)->lastRun())->toBeNull();
+});
+
+it('calls a run from long ago stale', function () {
+ // Ein toter Waechter muss als solcher lesbar sein. Bisher wuerde niemand
+ // es je erfahren.
+ File::ensureDirectoryExists(storage_path('app/deploy'));
+ File::put(storage_path('app/deploy/watchdog-last-run.json'), json_encode([
+ 'at' => now()->subMinutes(30)->utc()->format('Y-m-d\TH:i:s\Z'),
+ 'outcome' => 'idle',
+ 'actions' => [],
+ ]));
+
+ $run = app(WatchdogLog::class)->lastRun();
+
+ expect($run['stale'])->toBeTrue();
+});
+
+it('does not call a fresh run stale', function () {
+ 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',
+ 'actions' => [],
+ ]));
+
+ expect(app(WatchdogLog::class)->lastRun()['stale'])->toBeFalse();
+});