<?php // tests/Feature/Security/ScanForIntrusionsTest.php

use App\Models\Host;
use App\Models\Instance;
use App\Models\SecurityBlock;
use App\Provisioning\Jobs\ScanForIntrusions;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Ssh\FakeRemoteShell;
use App\Services\Ssh\RemoteShell;
use Illuminate\Support\Carbon;

beforeEach(function () {
    // Jede Instanz hat unten (aktiveInstanz()) einen echten Host — also kann
    // BlockAddress ueber HostFirewall auch in Tests, denen es nur ums Lesen
    // geht, eine SSH-Verbindung versuchen. Ohne einen gebundenen Fake liefe
    // das gegen eine echte, nicht erreichbare Adresse und haengt am
    // TCP-Verbindungsaufbau, statt sofort false zurueckzugeben. Tests, die den
    // Shell-Verkehr selbst pruefen, binden ihren eigenen Fake und ueberschreiben
    // diesen hier.
    app()->instance(RemoteShell::class, new FakeRemoteShell);
});

function protokollZeilen(string $ip, int $anzahl): string
{
    return collect(range(1, $anzahl))
        ->map(fn () => json_encode([
            'app' => 'core',
            'message' => "Login failed: 'admin' (Remote IP: '{$ip}')",
            'remoteAddr' => $ip,
            'time' => now()->toIso8601String(),
        ]))
        ->implode("\n");
}

/**
 * Eine aktive Instanz mit vmid braucht in Wahrheit immer einen Host — ohne
 * Platzierung gäbe es keinen Gastagenten zu fragen (siehe
 * IssueInstanceAdminAccess, das genau deshalb auf `$instance->host === null`
 * prüft). Der Auftragszettel ließ host_id in seinen Fixturen weg; das hätte
 * FailedLoginReader::fromInstance() nie erreicht, weil sie ohne Host abbricht.
 */
function aktiveInstanz(array $attributes = []): Instance
{
    return Instance::factory()->create(array_merge([
        'status' => 'active',
        'vmid' => 101,
        'host_id' => Host::factory()->active()->create()->id,
        // Versatz 1 statt 0, und das ist kein Detail: 0 heisst seit der
        // Fix-Welle woertlich „noch nie gelesen" und laesst den Lauf NUR den
        // Startwert holen, ohne das Protokoll zu uebertragen (siehe den
        // eigenen Testfall dafuer weiter unten). Eine Instanz, die schon
        // einmal gelesen wurde, ist der Normalfall dieser Datei.
        'security_log_offset' => 1,
    ], $attributes));
}

it('sperrt ab zehn Fehlversuchen im Fenster', function () {
    $pve = new FakeProxmoxClient;
    $pve->guestScripts['nextcloud.log'] = ['out-data' => protokollZeilen('203.0.113.7', 10), 'exitcode' => 0];
    app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve);

    aktiveInstanz();
    app(ScanForIntrusions::class)->handle();

    expect(SecurityBlock::where('ip', '203.0.113.7')->exists())->toBeTrue();
});

it('sperrt bei neun Fehlversuchen nicht', function () {
    $pve = new FakeProxmoxClient;
    $pve->guestScripts['nextcloud.log'] = ['out-data' => protokollZeilen('203.0.113.7', 9), 'exitcode' => 0];
    app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve);

    aktiveInstanz();
    app(ScanForIntrusions::class)->handle();

    expect(SecurityBlock::count())->toBe(0);
});

it('sperrt nicht, wenn sich die Versuche ueber zwei Fenster verteilen', function () {
    // Zehn Versuche sind erst dann zehn, wenn sie im selben Fenster liegen. Wer
    // langsam durchprobiert, laeuft absichtlich durch — das ist der Preis
    // dafuer, dass ein vertippter Mitarbeiter nicht ausgesperrt wird.
    $alt = collect(range(1, 6))->map(fn () => json_encode([
        'message' => "Login failed: 'admin' (Remote IP: '203.0.113.7')",
        'remoteAddr' => '203.0.113.7',
        'time' => now()->subMinutes(30)->toIso8601String(),
    ]))->implode("\n");

    $pve = new FakeProxmoxClient;
    $pve->guestScripts['nextcloud.log'] = ['out-data' => $alt."\n".protokollZeilen('203.0.113.7', 6), 'exitcode' => 0];
    app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve);

    aktiveInstanz();
    app(ScanForIntrusions::class)->handle();

    expect(SecurityBlock::count())->toBe(0);
});

it('sperrt auch, wenn sich die zehn Versuche ueber mehrere Laeufe verteilen', function () {
    // Der geduldige Angreifer. Drei Versuche je Lauf, vier Laeufe — zwoelf
    // Versuche innerhalb des Zehn-Minuten-Fensters. Zaehlte jeder Lauf nur fuer
    // sich (nur den eigenen Zuwachs seit dem letzten Mal), kaeme dieser
    // Angreifer nie ueber die Schwelle, und die Zusage "10 in 10 Minuten"
    // waere in Wahrheit "10 im Zuwachs eines einzelnen Laufs" — meist eine
    // Minute.
    aktiveInstanz();
    $pve = new FakeProxmoxClient;
    app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve);

    $offset = 0;
    foreach (range(1, 4) as $lauf) {
        // Je Lauf NEUE Zeilen im Protokoll, nicht dieselben noch einmal —
        // 'stat -c %s' zuerst registriert (wie im Rotations-Test), damit die
        // Groessen-Abfrage nicht denselben Treffer wie 'tail' bekommt.
        $neu = protokollZeilen('203.0.113.7', 3);
        $pve->guestScripts['stat -c %s'] = ['out-data' => ($offset + strlen($neu))."\n", 'exitcode' => 0];
        $pve->guestScripts['nextcloud.log'] = ['out-data' => $neu, 'exitcode' => 0];

        app(ScanForIntrusions::class)->handle();

        $offset += strlen($neu);
    }

    expect(SecurityBlock::where('ip', '203.0.113.7')->count())->toBe(1);
});

it('sperrt nicht, wenn neun Versuche ueber mehrere Laeufe unter der Schwelle bleiben', function () {
    // Die Gegenprobe zum Test oben: ohne sie waere der auch dann gruen, wenn
    // ScanForIntrusions gar nicht mehr zaehlte, sondern bei jedem Lauf ab dem
    // ersten Treffer sperrte.
    aktiveInstanz();
    $pve = new FakeProxmoxClient;
    app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve);

    $offset = 0;
    foreach ([2, 2, 2, 3] as $anzahl) {
        $neu = protokollZeilen('203.0.113.7', $anzahl);
        $pve->guestScripts['stat -c %s'] = ['out-data' => ($offset + strlen($neu))."\n", 'exitcode' => 0];
        $pve->guestScripts['nextcloud.log'] = ['out-data' => $neu, 'exitcode' => 0];

        app(ScanForIntrusions::class)->handle();

        $offset += strlen($neu);
    }

    expect(SecurityBlock::count())->toBe(0);
});

it('faengt bei einem rotierten Protokoll wieder bei null an', function () {
    // Ist die Datei kleiner als der gemerkte Versatz, wurde rotiert. Ohne diese
    // Behandlung liest der nächste Lauf ins Leere und sieht nie wieder etwas.
    $pve = new FakeProxmoxClient;
    $pve->guestScripts['stat -c %s'] = ['out-data' => "50\n", 'exitcode' => 0];
    $pve->guestScripts['nextcloud.log'] = ['out-data' => protokollZeilen('203.0.113.7', 10), 'exitcode' => 0];
    app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve);

    $instance = aktiveInstanz(['security_log_offset' => 999999]);
    app(ScanForIntrusions::class)->handle();

    expect($instance->fresh()->security_log_offset)->toBeLessThan(999999)
        ->and(SecurityBlock::count())->toBe(1);
});

it('traegt eine noch gueltige Sperre mit der RESTLAUFZEIT wieder ein', function () {
    // Nach einem Neustart des Hosts ist die nftables-Menge leer — sie lebt im
    // Speicher. Würde die ursprüngliche Dauer erneut gesetzt, verlängerte sich
    // eine Sperre bei jedem Neustart.
    //
    // Uhr eingefroren, UND auf die volle Sekunde — beides zaehlt. Ohne das
    // Einfrieren verstreicht zwischen dem Anlegen der Sperre und dem
    // Wiedereintragen echte Zeit; ohne die volle Sekunde traegt `now()`
    // Mikrosekunden, die `expires_at` nach dem Runden-durch-die-Datenbank
    // (die Spalte kennt keine Bruchteile) nicht mehr hat — genau die
    // Differenz, um die 1200s sonst auf 1199s abrundet.
    // finally: bricht eine Zusicherung weiter unten ab, bleibt die Uhr sonst
    // fuer alle Folgetests eingefroren stehen, und jemand sucht stundenlang
    // am falschen Ende.
    Carbon::setTestNow(now()->startOfSecond());

    try {
        $shell = new \App\Services\Ssh\FakeRemoteShell;
        app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);

        // Der Auftragszettel legt die Sperre ohne Host an — SecurityBlockFactory
        // haengt sie sonst an eine Instanz ohne host_id, und ohne Host gibt es
        // nichts, wo HostFirewall::block() etwas eintragen koennte. Mit `forHost`
        // (Aufgabe 3) direkt an einem echten Host, wie es eine host_ssh-Sperre
        // ohnehin waere.
        $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);
        $block = SecurityBlock::factory()->forHost($host)->create([
            'expires_at' => now()->addMinutes(20),
            'blocked_at' => now()->subMinutes(40),
        ]);

        app(ScanForIntrusions::class)->handle();

        expect($shell->ran('timeout 1200s'))->toBeTrue()
            ->and($shell->ran('timeout 3600s'))->toBeFalse();
    } finally {
        Carbon::setTestNow();
    }
});

it('laesst einen werfenden Gast den Lauf nicht abbrechen', function () {
    // Ein abgeschalteter Gast wirft, statt einen Fehlercode zu liefern —
    // guestExec() ruft ->throw() auf jede Antwort. Vorher riss das den ganzen
    // Minutentakt mit: die uebrigen Instanzen, die Hosts, und vor allem das
    // Wiedereintragen der noch gueltigen Sperren.
    //
    // Uhr eingefroren wie im Restlaufzeit-Test darueber, aus demselben Grund:
    // die zweite Zusicherung prueft eine exakte Sekundenzahl.
    Carbon::setTestNow(now()->startOfSecond());

    try {
        $pve = new FakeProxmoxClient;
        app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve);

        $shell = new \App\Services\Ssh\FakeRemoteShell;
        app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);

        // Erste Instanz: guestExec wirft (abgeschalteter Gast, kein sauberer
        // Fehlercode).
        aktiveInstanz(['vmid' => 201]);
        $pve->guestThrows[201] = new RuntimeException('guest agent unreachable');

        // Zweite Instanz: zehn Fehlversuche im Protokoll — muss trotz der
        // ersten weiterhin gelesen und gesperrt werden.
        aktiveInstanz(['vmid' => 202]);
        $pve->guestScripts['nextcloud.log'] = ['out-data' => protokollZeilen('203.0.113.7', 10), 'exitcode' => 0];

        // Dazu eine noch gueltige, UNBETEILIGTE Sperre (andere Adresse, eigener
        // Host), die wiedereingetragen werden muss — mit ihrer Restlaufzeit,
        // nicht mit der Basisdauer einer frisch ausgeloesten Sperre. Nur so
        // beweist die zweite Zusicherung wirklich, dass reapplyActiveBlocks()
        // gelaufen ist, statt bloss zufaellig mit der neu ausgeloesten Sperre
        // (die selbst schon "timeout" enthaelt) zusammenzufallen.
        $andererHost = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:def']);
        SecurityBlock::factory()->forHost($andererHost)->create([
            'ip' => '198.51.100.9',
            'expires_at' => now()->addMinutes(20),
            'blocked_at' => now()->subMinutes(40),
        ]);

        app(ScanForIntrusions::class)->handle();

        // Der Lauf ist durchgelaufen: die zweite Instanz wurde gesperrt UND die
        // bestehende, unbeteiligte Sperre wurde erneut eingetragen.
        expect(SecurityBlock::where('ip', '203.0.113.7')->exists())->toBeTrue()
            ->and($shell->ran('timeout 1200s'))->toBeTrue();
    } finally {
        Carbon::setTestNow();
    }
});

it('liest beim ERSTEN Lauf gar nichts, sondern merkt sich nur die Groesse', function () {
    // Versatz 0 heisst woertlich „von vorn": ohne diesen Zweig tailt der
    // allererste Lauf die KOMPLETTE Protokolldatei jeder Instanz durch den
    // Gastagenten, alle Instanzen im selben Auftrag, in derselben Minute.
    // Gesperrt wuerde dabei ohnehin nichts — das Zehn-Minuten-Fenster wirft
    // jede aeltere Zeile weg —, es waere also eine grosse Uebertragung fuer ein
    // garantiert leeres Ergebnis.
    $pve = new FakeProxmoxClient;
    $pve->guestScripts['stat -c %s'] = ['out-data' => "8123\n", 'exitcode' => 0];
    $pve->guestScripts['nextcloud.log'] = ['out-data' => protokollZeilen('203.0.113.7', 50), 'exitcode' => 0];
    app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve);

    $instance = aktiveInstanz(['security_log_offset' => 0]);
    app(ScanForIntrusions::class)->handle();

    expect($instance->fresh()->security_log_offset)->toBe(8123)
        ->and(SecurityBlock::count())->toBe(0)
        // Und die Uebertragung selbst hat nicht stattgefunden — sonst waere
        // dieser Test auch dann gruen, wenn nur das Zaehlen unterbliebe.
        ->and($pve->guestRan('tail -c'))->toBeFalse();
});

it('faengt beim ZWEITEN Lauf dort an, wo der erste aufgehoert hat', function () {
    // Die Gegenprobe: der Startwert darf die Instanz nicht dauerhaft blind
    // machen. Nach dem ersten Lauf wird wieder gelesen und gesperrt.
    $pve = new FakeProxmoxClient;
    $pve->guestScripts['stat -c %s'] = ['out-data' => "8123\n", 'exitcode' => 0];
    app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve);

    aktiveInstanz(['security_log_offset' => 0]);
    app(ScanForIntrusions::class)->handle();

    $neu = protokollZeilen('203.0.113.7', 10);
    $pve->guestScripts['stat -c %s'] = ['out-data' => (8123 + strlen($neu))."\n", 'exitcode' => 0];
    $pve->guestScripts['nextcloud.log'] = ['out-data' => $neu, 'exitcode' => 0];

    app(ScanForIntrusions::class)->handle();

    expect(SecurityBlock::where('ip', '203.0.113.7')->exists())->toBeTrue();
});

// ---- Was aus dem Gast-Protokoll kommt, ist keine Adresse, bis es geprueft ist ----

it('sperrt nicht auf eine Adresse, in der ein Semikolon steht, und fuehrt nichts aus', function () {
    // `remoteAddr` ist eine beliebige Zeichenkette aus einer Datei IM GAST. Der
    // Weg von hier fuehrt in ein `nft`-Kommando, das als root auf dem Host
    // laeuft — ein Semikolon darin waere ein zweiter Befehl.
    $boese = '203.0.113.7; nft flush ruleset';

    $shell = new FakeRemoteShell;
    app()->instance(RemoteShell::class, $shell);

    $pve = new FakeProxmoxClient;
    $pve->guestScripts['nextcloud.log'] = ['out-data' => protokollZeilen($boese, 20), 'exitcode' => 0];
    app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve);

    aktiveInstanz();
    app(ScanForIntrusions::class)->handle();

    expect(SecurityBlock::count())->toBe(0)
        ->and($shell->ran('nft'))->toBeFalse()
        ->and($shell->ran('flush ruleset'))->toBeFalse();
});

it('sperrt im Instanz-Zweig keine private Adresse, eine oeffentliche schon', function () {
    // Vorsichtsmassnahme, nicht Sicherheitsgrenze: TRUSTED_PROXIES steht in der
    // .env des Gastes nicht, waehrend Traefik auf dem Host sitzt. Was Nextcloud
    // als remoteAddr protokolliert, ist damit entweder die Brueckenadresse des
    // Hosts oder ein X-Forwarded-For, das der Client selbst waehlt — beides ist
    // privat oder reserviert, ein echter Angreifer ist es nicht.
    $pve = new FakeProxmoxClient;
    $pve->guestScripts['nextcloud.log'] = [
        'out-data' => protokollZeilen('172.17.0.1', 20)."\n".protokollZeilen('203.0.113.7', 10),
        'exitcode' => 0,
    ];
    app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve);

    aktiveInstanz();
    app(ScanForIntrusions::class)->handle();

    expect(SecurityBlock::where('ip', '172.17.0.1')->exists())->toBeFalse()
        ->and(SecurityBlock::where('ip', '203.0.113.7')->exists())->toBeTrue();
});

it('faengt einen werfenden Host ab, statt den ganzen Lauf abzubrechen', function () {
    // Derselbe Ring wie bei den Instanzen. Wirft hier etwas, liefe
    // reapplyActiveBlocks() nicht — jede Minute erneut.
    Carbon::setTestNow(now()->startOfSecond());

    try {
        $shell = new FakeRemoteShell;
        app()->instance(RemoteShell::class, $shell);

        // Ein Host, dessen Lesen wirft — stellvertretend fuer alles, was
        // NACH FailedLoginReader::fromHost()s eigenem catch noch schiefgehen
        // kann: SecurityBlock::create(), Settings::set(), Spaties
        // PermissionDoesNotExist, $host->save().
        Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);

        app()->bind(\App\Services\Security\FailedLoginReader::class, fn () => new class extends \App\Services\Security\FailedLoginReader
        {
            public function __construct() {}

            public function fromInstance(Instance $instance): ?array
            {
                return null;
            }

            public function fromHost(Host $host): ?array
            {
                throw new RuntimeException('Host-Lesen kaputt');
            }
        });

        // Eine noch gueltige, unbeteiligte Sperre, die trotzdem wieder
        // eingetragen werden muss.
        $andererHost = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:def']);
        SecurityBlock::factory()->forHost($andererHost)->create([
            'ip' => '198.51.100.9',
            'expires_at' => now()->addMinutes(20),
            'blocked_at' => now()->subMinutes(40),
        ]);

        app(ScanForIntrusions::class)->handle();

        expect($shell->ran('timeout 1200s'))->toBeTrue();
    } finally {
        Carbon::setTestNow();
    }
});

it('ueberspringt einen Gast, der nicht antwortet, ohne den Versatz zu verlieren', function () {
    $pve = new FakeProxmoxClient;
    $pve->guestScripts['nextcloud.log'] = ['exitcode' => 1];
    app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve);

    $instance = aktiveInstanz(['security_log_offset' => 4711]);
    app(ScanForIntrusions::class)->handle();

    expect($instance->fresh()->security_log_offset)->toBe(4711);
});

it('nimmt eine aufgehobene Sperre nachtraeglich aus der Firewall', function () {
    // Die Luecke, um die es geht: war der Host im Moment des Aufhebens nicht
    // erreichbar, steht der Datensatz auf aufgehoben und die Regel noch drin.
    // Portal und Konsole zeigen dann "Aufgehoben", waehrend die Adresse weiter
    // ausgesperrt bleibt — bis zum Ablauf der urspruenglichen Sperrzeit, ohne
    // dass es dem Menschen davor jemand sagt.
    $shell = new FakeRemoteShell;
    app()->instance(RemoteShell::class, $shell);

    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:def']);
    SecurityBlock::factory()->forHost($host)->create([
        'ip' => '198.51.100.9',
        'blocked_at' => now()->subMinutes(40),
        'expires_at' => now()->addMinutes(20), // noch nicht abgelaufen
        'released_at' => now()->subMinute(),   // aber schon aufgehoben
    ]);

    app(ScanForIntrusions::class)->handle();

    expect($shell->ran('delete element inet clupilot_filter clupilot_blocked { 198.51.100.9 }'))->toBeTrue();
});

it('ruehrt eine abgelaufene Sperre nicht mehr an', function () {
    // Nach der Ablaufzeit hat der Kernel den Eintrag selbst herausgenommen
    // (`flags timeout`). Jede Minute erneut loeschen zu wollen, was seit Wochen
    // weg ist, waere unnoetiger SSH-Verkehr auf demselben Arbeiter, der die
    // bezahlte Bereitstellung faehrt.
    $shell = new FakeRemoteShell;
    app()->instance(RemoteShell::class, $shell);

    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:def']);
    SecurityBlock::factory()->forHost($host)->create([
        'ip' => '198.51.100.9',
        'blocked_at' => now()->subDays(3),
        'expires_at' => now()->subDays(2),
        'released_at' => now()->subDays(2),
    ]);

    app(ScanForIntrusions::class)->handle();

    expect($shell->ran('delete element'))->toBeFalse();
});

it('laesst eine Adresse stehen, die eine ANDERE aktive Sperre noch traegt', function () {
    // Dieselbe Adresse kann an zwei Subjekten desselben Hosts haengen (Host und
    // Instanz, oder zwei Instanzen). Wer eine davon aufhebt, darf die andere
    // nicht gleich mit aufheben — sonst oeffnet das Aufheben einer harmlosen
    // Sperre die Tuer, die eine ganz andere zugehalten hat.
    $shell = new FakeRemoteShell;
    app()->instance(RemoteShell::class, $shell);

    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:def']);
    $instanz = Instance::factory()->create(['host_id' => $host->id]);

    SecurityBlock::factory()->forHost($host)->create([
        'ip' => '198.51.100.9',
        'blocked_at' => now()->subMinutes(40),
        'expires_at' => now()->addMinutes(20),
        'released_at' => now()->subMinute(),
    ]);

    SecurityBlock::factory()->for($instanz)->create([
        'ip' => '198.51.100.9', // dieselbe Adresse, noch aktiv
        'blocked_at' => now()->subMinutes(10),
        'expires_at' => now()->addMinutes(50),
    ]);

    app(ScanForIntrusions::class)->handle();

    expect($shell->ran('delete element'))->toBeFalse()
        ->and($shell->ran('add element inet clupilot_filter clupilot_blocked { 198.51.100.9'))->toBeTrue();
});
