Tunnel-Rettung aus der Konsole, Waechter und Wirt-Helfer sichtbar

Neue Anfrageart rescue-tunnel im bestehenden Update-Postkasten (kein
zweiter Weg): UpdateChannel::requestRescueTunnel(), der Agent fuehrt
deploy/rescue-tunnel.sh mit Frist aus, die Konsole zeigt Ergebnis,
Waechter-Stand und Wirt-Helfer-Vertrag in der Update-Karte, Bestaetigung
im Modal (R23) nach dem Muster von ConfirmReleaseUpdateLock.

Zusatzfix in deploy/watchdog.sh: der wg0-Block meldete geheilt=true
schon, bevor geprueft war, ob wg-quick up wg0 wirklich gewirkt hat --
ein fehlgeschlagener Tunnelaufbau haette der Konsole "healed" vorgemacht.
geheilt wird jetzt erst nach der zweiten wg-show-Probe gesetzt, mit
Regressionstest in WatchdogVisibilityTest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
nexxo 2026-08-04 17:59:39 +02:00
parent a51220f2de
commit 9d1811beea
11 changed files with 365 additions and 1 deletions

View File

@ -0,0 +1,33 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Rückfrage, bevor die Tunnel-Rettung auf dem Wirt läuft (R23).
*
* Das Modal mutiert nichts. Es wirft ein Ereignis, das
* App\Livewire\Admin\Settings auffängt; die Berechtigungsprüfung bleibt dort,
* wo sie schon steht, statt hier verdoppelt zu werden.
*/
class ConfirmRescueTunnel extends ModalComponent
{
public function mount(): void
{
$this->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');
}
}

View File

@ -12,6 +12,7 @@ use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use App\Services\Deployment\UpdateChannel;
use App\Services\Deployment\UpdateWindow;
use App\Services\Deployment\WatchdogLog;
use App\Services\Terminal\ServerIdentity;
use App\Services\Terminal\ServerTerminalSetup;
use App\Services\Terminal\ServerTicket;
@ -814,6 +815,28 @@ class Settings extends Component
: 'admin_settings.release_lock_already_requested'));
}
/**
* Den Tunnel wieder hinbekommen.
*
* Der Auslöser kommt aus dem Bestätigungs-Modal (R23); die Prüfung steht
* hier, wie bei jeder anderen Handlung dieser Seite.
*/
#[On('rescue-tunnel-confirmed')]
public function rescueTunnel(): void
{
$this->authorize('site.manage');
if (! $operator = $this->currentOperator()) {
return;
}
$accepted = app(UpdateChannel::class)->requestRescueTunnel($operator->email);
$this->dispatch('notify', message: __($accepted
? 'admin_settings.rescue_requested'
: 'admin_settings.update_already_requested'));
}
public function render()
{
$operator = $this->currentOperator();
@ -838,6 +861,9 @@ class Settings extends Component
return view('livewire.admin.settings', [
'update' => app(UpdateChannel::class)->state(),
'updateLog' => app(UpdateChannel::class)->lastLog(),
// Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht —
// er redet ins Journal, und das liegt auf dem Wirt.
'watchdog' => app(WatchdogLog::class)->lastRun(),
'sitePublic' => AppSettings::bool('site.public', true),
'canManageSite' => $operator?->can('site.manage') ?? false,
// Shown so nobody has to guess why they still see the real site.

View File

@ -142,6 +142,18 @@ final class UpdateChannel
*/
private const ARCHIVE_KEY = 'deploy/archive-key.json';
/**
* Den Tunnel wieder hinbekommen.
*
* `deploy/rescue-tunnel.sh` stand in zwei Runbooks als Handarbeit. Es
* läuft als Dienstbenutzer und tut ausdrücklich nichts Zerstörerisches
* kein Neubau, kein Neustart des Tunnel-Containers, weil genau das die
* Ursache wäre und nicht die Lösung.
*/
private const KIND_RESCUE_TUNNEL = 'rescue-tunnel';
private const RESCUE_LAST_RUN = 'deploy/rescue-last-run.json';
/** Written by the agent after every check and every run. */
private const STATUS = 'deploy/update-status.json';
@ -407,6 +419,11 @@ final class UpdateChannel
'last_restart_state' => $restartLastRun['state'] ?? null,
'last_restart_finished_at' => $this->timestamp($restartLastRun['finished_at'] ?? null),
'last_restart_error' => $this->errorMessage($restartLastRun),
// `readJson` fängt kaputtes JSON bereits ab und liefert `[]`.
'rescue_last_run' => ($last = $this->readJson(self::RESCUE_LAST_RUN)) !== []
? $last
: null,
];
}
@ -604,6 +621,15 @@ final class UpdateChannel
return $this->submit($by, self::KIND_CHECK);
}
/**
* Den Tunnel wieder hinbekommen kein zweiter Weg, derselbe Postkasten
* wie jede andere Anfrageart.
*/
public function requestRescueTunnel(string $by): bool
{
return $this->submit($by, self::KIND_RESCUE_TUNNEL);
}
/**
* Den Server auf eine Version festnageln oder die Decke abnehmen.
*

View File

@ -681,6 +681,34 @@ if [[ "$REQUEST_KIND" == "proxy-hosts" ]]; then
exit 0
fi
if [[ "$REQUEST_KIND" == "rescue-tunnel" ]]; then
# Läuft als derselbe Dienstbenutzer wie dieser Agent — genau so, wie das
# Runbook es von Hand vorsah. Das Skript weigert sich als root zu laufen.
#
# Frist, weil dieser Aufruf die Sperre hält: die Tunnel-Rettung fasst
# `docker compose exec` an, und ein hängender Docker-Daemon hätte den
# Agenten sonst stundenlang blockiert — dieselbe Ausfallart, die diese
# Anlage schon zweimal hatte.
RESCUE_STATE=ok
RESCUE_ERROR=''
if ! timeout -k 10 180 bash "$ROOT/deploy/rescue-tunnel.sh" > "$STATE_DIR/rescue-last-run.log" 2>&1; then
RESCUE_STATE=failed
RESCUE_ERROR=rescue_failed
fi
cat > "$STATE_DIR/rescue-last-run.json.tmp" <<EOF
{
"state": "$(json_escape "$RESCUE_STATE")",
"finished_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"error": "$(json_escape "$RESCUE_ERROR")"
}
EOF
mv -f "$STATE_DIR/rescue-last-run.json.tmp" "$STATE_DIR/rescue-last-run.json"
write_status idle
exit 0
fi
if [[ "$REQUEST_KIND" == "restart" ]]; then
# queue, queue-provisioning, scheduler, reverb — and ONLY those four.
# `app` is deliberately never in this list: it is the container serving

View File

@ -164,10 +164,16 @@ 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."
fi

View File

@ -139,6 +139,18 @@ return [
'update_agent_blocked' => 'Der Update-Dienst läuft, kommt aber seit :since nicht an die Arbeit — ein anderer Vorgang hält die Sperre. Solange das so ist, stammen die Angaben oben von vorher. Löst es sich nicht von selbst, hilft ein Blick auf den Prozess unten.',
'update_log' => 'Protokoll des letzten Laufs',
// ── Der Wächter ──────────────────────────────────────────────────────
// Redet bisher nur ins Journal auf dem Wirt — die Konsole sah ihn bis
// August 2026 gar nicht (App\Services\Deployment\WatchdogLog).
'watchdog_idle' => 'Zuletzt nachgesehen :when — nichts zu tun.',
'watchdog_healed' => 'Zuletzt eingegriffen :when: :what',
'watchdog_stood_down' => ':when zurückgetreten, weil ein Update lief.',
'watchdog_stale' => 'Der Wächter hat sich seit :when nicht gemeldet — er läuft vermutlich nicht mehr.',
// ── Der Wirt-Helfer ──────────────────────────────────────────────────
// Der root-eigene Helfer auf dem Wirt (UpdateChannel::state()['host_step_ok']).
'host_step_old' => 'Der Wirt-Helfer erfüllt Vertrag :have, gebraucht wird :needs. Einmalig auf diesem Wirt ausführen:',
// ── Die Sperre lösen ─────────────────────────────────────────────────
// Der Griff, der bisher per SSH auf dem Wirt lag. Er beendet einen
// laufenden Prozess, deshalb nennt die Rückfrage ihn vorher beim Namen.
@ -152,6 +164,17 @@ return [
'release_lock_requested' => 'Wird gelöst — der Dienst meldet sich binnen einer Minute zurück.',
'release_lock_already_requested' => 'Ist schon angefordert — bitte kurz warten.',
// ── Tunnel-Rettung ───────────────────────────────────────────────────
// deploy/rescue-tunnel.sh stand bisher in zwei Runbooks als Handarbeit.
// Derselbe Postkasten wie jede andere Anfrageart
// (UpdateChannel::KIND_RESCUE_TUNNEL) — kein zweiter Weg.
'rescue_action' => 'Tunnel retten',
'rescue_requested' => 'Tunnel-Rettung angefordert.',
'rescue_title' => 'Den Tunnel jetzt retten?',
'rescue_body' => 'Zieht das WireGuard-Interface auf dem Tunnel-Server neu hoch. Tut ausdrücklich nichts Zerstörerisches — kein Neubau, kein Neustart des Tunnel-Containers.',
'rescue_cancel' => 'Abbrechen',
'rescue_confirm' => 'Tunnel retten',
// ── Festnageln ────────────────────────────────────────────────────────
// Die Decke, die den Update-Agenten UND das Wartungsfenster begrenzt
// (App\Services\Deployment\UpdateChannel::setCeiling()). Bestätigt wird

View File

@ -136,6 +136,18 @@ return [
'update_agent_blocked' => 'The update service is running but has been unable to work since :since — another process holds the lock. While that lasts, the figures above are from before. If it does not clear on its own, look at the process named below.',
'update_log' => 'Log of the last run',
// ── The watchdog ─────────────────────────────────────────────────────
// It used to talk only to the host's journal — the console could not see
// it until August 2026 (App\Services\Deployment\WatchdogLog).
'watchdog_idle' => 'Last checked :when — nothing to do.',
'watchdog_healed' => 'Last intervened :when: :what',
'watchdog_stood_down' => 'Stood down :when because an update was running.',
'watchdog_stale' => 'The watchdog has not reported in since :when — it is probably no longer running.',
// ── The host helper ──────────────────────────────────────────────────
// The root-owned helper on the host (UpdateChannel::state()['host_step_ok']).
'host_step_old' => 'The host helper meets contract :have, this version needs :needs. Run once on this host:',
// ── Releasing the lock ───────────────────────────────────────────────
// The handle that used to live behind an SSH session. It ends a running
// process, so the confirmation names it before it does.
@ -149,6 +161,17 @@ return [
'release_lock_requested' => 'Releasing — the service reports back within a minute.',
'release_lock_already_requested' => 'Already requested — please wait a moment.',
// ── Rescuing the tunnel ──────────────────────────────────────────────
// deploy/rescue-tunnel.sh used to be manual work written down in two
// runbooks. The same mailbox as every other request kind
// (UpdateChannel::KIND_RESCUE_TUNNEL) — no second path.
'rescue_action' => 'Rescue tunnel',
'rescue_requested' => 'Tunnel rescue requested.',
'rescue_title' => 'Rescue the tunnel now?',
'rescue_body' => 'Raises the WireGuard interface on the tunnel server again. Deliberately does nothing destructive — no rebuild, no restart of the tunnel container.',
'rescue_cancel' => 'Cancel',
'rescue_confirm' => 'Rescue tunnel',
// ── Pinning a ceiling ────────────────────────────────────────────────
// The ceiling that limits both the update agent AND the maintenance
// window (App\Services\Deployment\UpdateChannel::setCeiling()). Only

View File

@ -0,0 +1,17 @@
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-accent-subtle text-accent-text">
<x-ui.icon name="life-buoy" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('admin_settings.rescue_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.rescue_body') }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('admin_settings.rescue_cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
<x-ui.icon name="life-buoy" class="size-4" />{{ __('admin_settings.rescue_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -117,6 +117,35 @@
<dd class="text-body">{{ $update['checked_at']->diffForHumans() }}</dd></div>
@endif
</dl>
{{-- Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht er redet
ins Journal, und das liegt auf dem Wirt. --}}
@if ($watchdog)
<p class="mt-2 text-sm {{ $watchdog['stale'] ? '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()]) }}
@else
{{ __('admin_settings.watchdog_idle', ['when' => $watchdog['at']->local()->diffForHumans()]) }}
@endif
</p>
@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'])
<x-ui.alert variant="warning" class="mt-2">
{{ __('admin_settings.host_step_old', [
'have' => $update['host_step_have'],
'needs' => $update['host_step_needs'],
]) }}
<code class="mt-1 block select-all text-xs">sudo bash /opt/clupilot/deploy/install-agent.sh</code>
</x-ui.alert>
@endunless
</div>
{{-- Bound, not @disabled(): the directive compiles to inline PHP
@ -174,6 +203,16 @@
{{ __('admin_settings.update_now') }}
</x-ui.button>
{{-- 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. --}}
<x-ui.button class="w-full sm:w-auto" variant="secondary"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-rescue-tunnel' })">
<x-ui.icon name="life-buoy" class="size-4" />
{{ __('admin_settings.rescue_action') }}
</x-ui.button>
{{-- 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

View File

@ -0,0 +1,73 @@
<?php
use App\Livewire\Admin\Settings;
use App\Models\Operator;
use App\Services\Deployment\UpdateChannel;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
/**
* `deploy/rescue-tunnel.sh` stand in zwei Runbooks als Handarbeit. Es läuft
* ohnehin als Dienstbenutzer und tut nichts Zerstörerisches der Agent kann
* es also fahren wie jede andere Anfrage.
*/
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('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();
$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');
});

View File

@ -61,6 +61,58 @@ function runWatchdog(string $running = "app\nredis", bool $holdLock = false): ar
return json_decode(File::get($dir.'/watchdog-last-run.json'), true);
}
/**
* Der wg0-Block, isoliert: vpn-hub laeuft, seine Konfiguration existiert,
* "wg show wg0" scheitert der Waechter darf eingreifen und ruft
* "wg-quick up wg0". Ob der Griff wirklich gewirkt hat, entscheidet
* $recovers: danach meldet "wg show wg0" entweder wieder oben (true) oder
* weiterhin unten (false) genau der Fall aus dem Task-1-Review, in dem
* `geheilt=true` VOR dieser zweiten Pruefung stand.
*/
function runWatchdogTunnelRescue(bool $recovers): array
{
$dir = storage_path('app/deploy');
File::ensureDirectoryExists($dir);
File::delete(File::glob($dir.'/*'));
$stub = $dir.'/stub';
File::ensureDirectoryExists($stub);
$wgShowAfterUp = $recovers ? 'exit 0' : 'exit 1';
File::put($stub.'/docker', <<<SH
#!/bin/sh
case "\$*" in
*config*) printf '%s\\n' app redis ;;
*"ps "*) printf '%s\\n' app redis vpn-hub ;;
*"vpn-hub test -f /etc/wireguard/wg0.conf"*) exit 0 ;;
*"vpn-hub wg-quick up wg0"*) touch "\$STUB_WG_UP_CALLED" ;;
*"vpn-hub wg show wg0"*)
if [ -f "\$STUB_WG_UP_CALLED" ]; then
{$wgShowAfterUp}
else
exit 1
fi
;;
*getent*) exit 0 ;;
*storage/framework/down*) exit 1 ;;
*" up "*) touch "\$STUB_UP_CALLED" ;;
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_UP_CALLED' => $dir.'/.stub-up-called',
'STUB_WG_UP_CALLED' => $dir.'/.stub-wg-up-called',
])->run('bash deploy/watchdog.sh >/dev/null 2>&1 || true');
expect($result->successful())->toBeTrue($result->errorOutput());
return json_decode(File::get($dir.'/watchdog-last-run.json'), true);
}
afterEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
@ -91,6 +143,24 @@ it('records that it stood down because the lock was held', function () {
expect($run['outcome'])->toBe('stood_down');
});
it('reports healed when raising wg0 actually brought the tunnel back', function () {
$run = runWatchdogTunnelRescue(recovers: true);
expect($run['outcome'])->toBe('healed')
->and(implode(' ', $run['actions']))->toContain('wg0 steht wieder');
});
it('does not claim healed when wg-quick ran but the tunnel stayed down', function () {
// Der Befund aus dem Task-1-Review: `geheilt` wurde wahr, bevor geprueft
// war, ob `wg-quick up wg0` ueberhaupt gewirkt hat. Die Konsole zeigt
// `outcome` jetzt einem Menschen — eine Luege hier waere ein Fehler, kein
// Journal-Detail mehr.
$run = runWatchdogTunnelRescue(recovers: false);
expect($run['outcome'])->not->toBe('healed')
->and(implode(' ', $run['actions']))->toContain('ACHTUNG: wg0 liess sich nicht hochziehen');
});
it('reads nothing rather than falling over when the file is absent', function () {
File::ensureDirectoryExists(storage_path('app/deploy'));