292 lines
12 KiB
PHP
292 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Admin\Settings;
|
|
use App\Models\Operator;
|
|
use App\Services\Deployment\UpdateChannel;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Livewire\Livewire;
|
|
|
|
/**
|
|
* `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'));
|
|
});
|
|
|
|
/**
|
|
* Baut eine `docker`- und `sudo`-Attrappe für deploy/rescue-tunnel.sh und
|
|
* gibt ihr Verzeichnis zurück. Hier läuft das ECHTE Skript, nicht eine
|
|
* Nachbildung seiner Logik — genau daran ist der letzte Fix schon einmal
|
|
* gescheitert (siehe WatchdogVisibilityTest).
|
|
*
|
|
* @param array<int, string> $peers Zeilen für "wg show wg0 peers" (leer = kein Zugang)
|
|
* @param array<int, string> $transfer Zeilen für "wg show wg0 transfer" ("pubkey empfangen gesendet")
|
|
*/
|
|
function rescueTunnelStub(array $peers = ['PEER1'], array $transfer = ['PEER1 0 999'], bool $sudoOk = true, bool $ownContainer = true): string
|
|
{
|
|
$dir = storage_path('app/deploy');
|
|
File::ensureDirectoryExists($dir);
|
|
|
|
$stub = $dir.'/stub-rescue';
|
|
File::ensureDirectoryExists($stub);
|
|
|
|
$peersOut = implode('\n', $peers);
|
|
$transferOut = implode('\n', $transfer);
|
|
$cmdlineOut = $ownContainer ? 'vpn-hub' : 'some-other-process';
|
|
|
|
File::put($stub.'/docker', <<<SH
|
|
#!/bin/sh
|
|
case "\$*" in
|
|
*"ps --status running vpn-hub"*) printf '%s\\n' vpn-hub ;;
|
|
*"vpn-hub sh -c"*) printf '%s\\n' '$cmdlineOut' ;;
|
|
*"vpn-hub wg-quick up wg0"*) exit 0 ;;
|
|
*"vpn-hub test -f /etc/wireguard/wg0.conf"*) exit 0 ;;
|
|
*"vpn-hub wg show wg0 peers"*) printf '%s\\n' '$peersOut' ;;
|
|
*"vpn-hub wg show wg0 transfer"*) printf '%s\\n' '$transferOut' ;;
|
|
*"vpn-hub wg show wg0"*) exit 0 ;;
|
|
*"vpn-hub ls -l /etc/wireguard/"*) exit 0 ;;
|
|
esac
|
|
exit 0
|
|
SH);
|
|
|
|
File::put($stub.'/sudo', $sudoOk
|
|
? "#!/bin/sh\nexit 0\n"
|
|
: "#!/bin/sh\nexit 1\n");
|
|
|
|
Process::run("chmod +x {$stub}/docker {$stub}/sudo");
|
|
|
|
return $stub;
|
|
}
|
|
|
|
it('puts a rescue request in the same mailbox as every other kind', function () {
|
|
// Kein zweiter Weg: dieselbe Datei, derselbe Agent, derselbe Lauf.
|
|
expect(app(UpdateChannel::class)->requestRescueTunnel('chef@example.com'))->toBeTrue();
|
|
|
|
$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'));
|
|
});
|