CluPilotCloud/docs/superpowers/plans/2026-08-03-fruehwarnsystem.md

41 KiB
Raw Blame History

Frühwarnsystem — Umsetzungsplan

Für agentische Arbeiter: ERFORDERLICHE UNTER-SKILL: superpowers:subagent-driven-development (empfohlen) oder superpowers:executing-plans, um diesen Plan Aufgabe für Aufgabe umzusetzen. Die Schritte benutzen Kästchen (- [ ]) zum Mitführen.

Ziel: Anmeldeversuche gegen Kundeninstanzen und Hosts erkennen, die Adresse des Angreifers zeitlich befristet in der Host-Firewall sperren, den Betroffenen benachrichtigen und ihm den Knopf zum Aufheben geben — ohne dass eine laufende Sitzung abreißt.

Aufbau: Ein Zeitplan-Auftrag liest je Instanz das Nextcloud-Protokoll (über den Proxmox-Gastagenten) und je Host das SSH-Journal (über SSH im Tunnel), zählt Fehlversuche in einem gleitenden Fenster und legt bei Überschreitung einen SecurityBlock an. Gesperrt wird als Element einer nftables-Menge mit Ablaufzeit, deren Regel unter ct state established,related accept steht — bestehende Verbindungen bleiben damit unberührt, und die Frist verwaltet der Kernel.

Technik: Laravel 13.8, Livewire 3 (klassenbasiert), Tailwind, Pest, MariaDB (Tests: SQLite im Speicher), nftables auf dem Host, Proxmox-Gastagent für Gäste.

Spec: docs/superpowers/specs/2026-08-03-fruehwarnsystem-design.md — bei Widerspruch gilt die Spec.

Verbindliche Rahmenbedingungen

  • Schwelle: 10 Fehlversuche in 10 Minuten von derselben Adresse.
  • Dauer: erste Sperre 1 Stunde. Erneute Sperre derselben Adresse am selben Subjekt innerhalb von 24 Stunden verdoppelt: 1 h → 2 h → 4 h → 8 h → 16 h → 24 h Obergrenze.
  • Niemals gesperrt: das Verwaltungsnetz 10.66.0.0/24, 127.0.0.1, ::1, sowie die öffentliche Adresse des CluPilot-Servers (CLUPILOT_WG_ENDPOINT ohne Port). Hart verdrahtet, ohne Schalter.
  • Die Sperrregel steht UNTER ct state established,related accept. Das ist die Zusage „wer drin ist, bleibt drin". Ein Test prüft die Reihenfolge im erzeugten Regelwerk, nicht im Quelltext.
  • Höchstens eine Mail je Instanz und Stunde.
  • Kommentare auf Deutsch, im Ton des umliegenden Codes: sie erklären das Warum, nicht das Was.
  • Repo-Regeln R18R24 aus CLAUDE.md gelten und werden per Test erzwungen. Besonders: R20 (Bearbeiten im Modal), R23 (Bestätigen im Modal, nie wire:confirm), R24.3 (keine Blade-Direktive in der Attributliste eines Komponenten-Tags).
  • Jeder Befehl im app-Container nennt seinen Benutzer (-u www-data bzw. -u root) — erzwungen durch tests/Feature/DeploymentRunsAsTheAppUserTest.php.
  • InstanceFactory kennt keinen active()-Zustand — Status und vmid werden in den Tests ausgeschrieben. Wer einen Zustand ergänzen will, tut das in einer eigenen Aufgabe, nicht nebenbei.
  • Kommandos laufen im Container: docker compose exec -u 1000:1000 -T app php artisan test docker compose exec -u 1000:1000 -e npm_config_cache=/tmp/npm-cache -T app npm run build

Dateien

Datei Verantwortung
app/Services/Mail/MailCatalogue.php neu — die eine Liste aller Mailarten: Schlüssel, lesbarer Name, Vorgabe-Zweck
app/Services/Mail/MailRoute.php neu — Wegwahl: welches Postfach trägt diese Mailart
app/Mail/Concerns/SendsFromMailbox.php ändern — fragt die Wegwahl vor dem Zweck
app/Services/Mail/MailPreviews.php ändern — liest den Katalog, führt keine eigene Liste mehr
app/Livewire/Admin/Mail.php + Blade ändern — Tabelle „welche Mail aus welchem Postfach"
app/Provisioning/Steps/Host/SecureHostFirewall.php ändern — zwei Mengen mit Ablaufzeit und die Sperrregel
app/Services/Security/HostFirewall.php neu — Elemente eintragen und entfernen
database/migrations/…_security_blocks.php neu — Tabelle plus zwei Cursor-Spalten
app/Models/SecurityBlock.php neu — Datensatz, Beziehungen, active()
app/Services/Security/BlockAddress.php neu — Schwelle, Verdopplung, Ausnahmeliste, Anlegen
app/Services/Security/FailedLoginReader.php neu — liest Gast-Protokoll und Host-Journal, liefert Adressen mit Zeitpunkt
app/Provisioning/Jobs/ScanForIntrusions.php neu — der Zeitplan-Auftrag
app/Mail/SecurityBlockMail.php + Blade neu — die Benachrichtigung
app/Livewire/Security.php + Blade neu — Portalseite des Inhabers
app/Livewire/ConfirmReleaseBlock.php + Blade neu — Bestätigung (R23)
Konsole: Instanz- und Host-Detailseite ändern — Abschnitt mit denselben Zeilen

Aufgabe 1: Aus welchem Postfach eine Mail geht

Dateien:

  • Erstellen: app/Services/Mail/MailCatalogue.php, app/Services/Mail/MailRoute.php
  • Ändern: app/Mail/Concerns/SendsFromMailbox.php, app/Services/Mail/MailPreviews.php, app/Livewire/Admin/Mail.php, resources/views/livewire/admin/mail.blade.php, lang/de/admin_mail.php, lang/en/admin_mail.php
  • Test: tests/Feature/Admin/MailRoutingTest.php

Schnittstellen:

  • Liefert: MailCatalogue::all(): array<string, array{label: string, purpose: string}>, MailRoute::purposeOrMailbox(string $key, string $purpose): ?Mailbox, MailRoute::settingKey(string $key): string

  • Aufgabe 5 benutzt MailCatalogue, um security-block einzutragen.

  • Schritt 1: Den fehlschlagenden Test schreiben

<?php // tests/Feature/Admin/MailRoutingTest.php

use App\Models\Mailbox;
use App\Services\Mail\MailCatalogue;
use App\Services\Mail\MailPurpose;
use App\Services\Mail\MailRoute;
use App\Support\Settings;

it('schickt ohne Eintrag genau dorthin, wo die Mail vorher hinging', function () {
    // Der wichtigste Test dieser Aufgabe: der Umbau darf am heutigen Verhalten
    // NICHTS ändern, solange niemand etwas einstellt. Sonst wandern beim
    // Ausrollen still die Rechnungen in ein anderes Postfach.
    $system = Mailbox::factory()->create(['key' => 'no-reply', 'active' => true]);
    Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply');

    expect(MailRoute::purposeOrMailbox('new-device', MailPurpose::SYSTEM)?->id)
        ->toBe($system->id);
});

it('legt eine einzelne Mailart auf ein anderes Postfach', function () {
    Mailbox::factory()->create(['key' => 'no-reply', 'active' => true]);
    $info = Mailbox::factory()->create(['key' => 'info', 'active' => true]);
    Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply');
    Settings::set(MailRoute::settingKey('new-device'), 'info');

    // Und nur diese eine: der Nachbar bleibt, wo er war. Mit einem EIGENEN
    // Postfach fuer Abrechnung — ohne das waere die Zusicherung leer, weil der
    // Resolver dann null liefert und null nun einmal nicht 'info' ist.
    $billing = Mailbox::factory()->create(['key' => 'billing', 'active' => true]);
    Settings::set(MailPurpose::settingKey(MailPurpose::BILLING), 'billing');

    expect(MailRoute::purposeOrMailbox('new-device', MailPurpose::SYSTEM)?->id)->toBe($info->id)
        ->and(MailRoute::purposeOrMailbox('invoice', MailPurpose::BILLING)?->id)->toBe($billing->id);
});

it('faellt auf den Zweck zurueck, wenn das eingetragene Postfach abgeschaltet ist', function () {
    // Eine Mail, die nicht rausgeht, ist schlimmer als eine aus der zweitbesten
    // Adresse.
    Mailbox::factory()->create(['key' => 'no-reply', 'active' => true]);
    Mailbox::factory()->create(['key' => 'info', 'active' => false]);
    Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply');
    Settings::set(MailRoute::settingKey('new-device'), 'info');

    expect(MailRoute::purposeOrMailbox('new-device', MailPurpose::SYSTEM)?->key)->toBe('no-reply');
});

it('kennt zu jeder Mailart einen Vorgabe-Zweck', function () {
    // Ohne Vorgabe stünde eine Mailart ohne Postfach da, sobald jemand die
    // Wegwahl leert.
    foreach (MailCatalogue::all() as $key => $eintrag) {
        expect($eintrag['purpose'])->toBeIn(MailPurpose::ALL, "[{$key}] hat keinen gueltigen Vorgabe-Zweck.");
        expect($eintrag['label'])->not->toBe('');
    }
});

it('fuehrt die Liste der Mailarten nur an EINER Stelle', function () {
    // Zwei Listen bedeuten, dass die zweite beim siebzehnten Mail vergessen
    // wird. Die Vorschau muss aus dem Katalog lesen.
    expect(array_keys(app(\App\Services\Mail\MailPreviews::class)->all()))
        ->toEqualCanonicalizing(array_keys(MailCatalogue::all()));
});
  • Schritt 2: Test laufen lassen, Fehlschlag bestätigen

Ausführen: docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Admin/MailRoutingTest.php Erwartet: FEHLER — Class "App\Services\Mail\MailCatalogue" not found

  • Schritt 3: Den Katalog anlegen

app/Services/Mail/MailCatalogue.php. Die Schlüssel und Beschriftungen wortgleich aus MailPreviews::all() übernehmen; der Vorgabe-Zweck ist der, den die jeweilige Mailklasse heute schon an mailboxEnvelope() übergibt (nachsehen, nicht raten).

<?php

namespace App\Services\Mail;

/**
 * Die eine Liste aller Mailarten, die dieses Haus verschickt.
 *
 * Es gab sie schon — als Array in MailPreviews, nur für die Vorschau. Mit der
 * Wegwahl je Mailart bräuchte es eine zweite, und die zweite Liste ist die, die
 * beim siebzehnten Mail vergessen wird. Also eine, aus der beide lesen.
 *
 * `purpose` ist die VORGABE: das Postfach, aus dem diese Mailart geht, solange
 * niemand etwas anderes einstellt. Sie muss dem entsprechen, was die Mailklasse
 * heute an mailboxEnvelope() übergibt — sonst ändert dieser Umbau still das
 * Verhalten.
 */
final class MailCatalogue
{
    /** @return array<string, array{label: string, purpose: string}> */
    public static function all(): array
    {
        return [
            'verify-email' => ['label' => 'E-Mail bestätigen (Registrierung)', 'purpose' => MailPurpose::SYSTEM],
            'reset-password' => ['label' => 'Passwort zurücksetzen', 'purpose' => MailPurpose::SYSTEM],
            // … alle übrigen Einträge aus MailPreviews::all(), mit dem Zweck,
            // den die zugehörige Mailklasse heute benutzt.
        ];
    }

    public static function label(string $key): string
    {
        return self::all()[$key]['label'] ?? $key;
    }

    public static function purpose(string $key): string
    {
        return self::all()[$key]['purpose'] ?? MailPurpose::SYSTEM;
    }
}
  • Schritt 4: Die Wegwahl anlegen
<?php

namespace App\Services\Mail;

use App\Models\Mailbox;
use App\Support\Settings;

/**
 * Welches Postfach trägt diese Mailart.
 *
 * Eine Ebene ÜBER den Zwecken, kein Ersatz für sie. Ohne Eintrag entscheidet
 * weiter der Zweck — der Umbau ändert also am heutigen Verhalten nichts, und
 * genau das ist die Bedingung dafür, dass man ihn gefahrlos ausrollen kann.
 */
final class MailRoute
{
    public static function settingKey(string $mailKey): string
    {
        return 'mail.route.'.$mailKey;
    }

    /**
     * Das Postfach für diese Mailart — der Eintrag, sonst der Zweck.
     *
     * Ein Eintrag, der auf ein gelöschtes oder abgeschaltetes Postfach zeigt,
     * fällt auf den Zweck zurück. Eine Mail, die nicht rausgeht, ist schlimmer
     * als eine aus der zweitbesten Adresse.
     */
    public static function purposeOrMailbox(string $mailKey, string $purpose): ?Mailbox
    {
        $key = (string) Settings::get(self::settingKey($mailKey), '');

        if ($key !== '') {
            $box = Mailbox::findByKey($key);

            if ($box !== null && $box->active) {
                return $box;
            }
        }

        return app(MailboxResolver::class)->for($purpose);
    }
}
  • Schritt 5: SendsFromMailbox die Mailart mitgeben

mailboxEnvelope() und mailboxAddresses() bekommen einen zusätzlichen Parameter ?string $mailKey = null. Ist er gesetzt, wird MailRoute::purposeOrMailbox($mailKey, $purpose) gefragt, sonst wie bisher MailboxResolver::for($purpose). Bestehende Aufrufer bleiben unverändert — der Vorgabewert null hält ihr Verhalten.

Danach jede Mailklasse um ihren Katalog-Schlüssel ergänzen, z. B. in NewDeviceSignInMail:

return $this->mailboxEnvelope(MailPurpose::SYSTEM, __('devices.mail_subject'), 'new-device');
  • Schritt 6: MailPreviews aus dem Katalog lesen lassen

MailPreviews::all() gibt array_map(fn ($e) => $e['label'], MailCatalogue::all()) zurück. Die handgeführte Liste entfällt; der Kommentar über den sechs Mahntexten wandert in den Katalog.

  • Schritt 7: Test laufen lassen, Erfolg bestätigen

Ausführen: docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Admin/MailRoutingTest.php Erwartet: BESTANDEN

  • Schritt 8: Die Tabelle in der Konsole

In app/Livewire/Admin/Mail.php neben $purposes ein public array $routes = [], in mount() je Katalog-Schlüssel aus Settings gefüllt, und saveRoutes() nach dem Muster von savePurposes() (dieselbe Berechtigung, dieselbe Meldung).

Im Blade unter den Zwecken eine Tabelle: je Zeile links MailCatalogue::label($key), rechts ein <select> mit allen aktiven Postfächern und einer ersten Option „wie der Zweck (…)" mit leerem Wert. Kein x-ui.modal, keine Direktive in einer Attributliste (R24.3).

  • Schritt 9: Volle Suite und Commit
docker compose exec -u 1000:1000 -T app php artisan test
git add app/Services/Mail app/Mail app/Livewire/Admin/Mail.php resources/views/livewire/admin/mail.blade.php lang tests/Feature/Admin/MailRoutingTest.php
git commit -m "Wegwahl je Mailart: welche Mail aus welchem Postfach geht"

Aufgabe 2: Die Sperrliste in der Host-Firewall

Dateien:

  • Ändern: app/Provisioning/Steps/Host/SecureHostFirewall.php
  • Erstellen: app/Services/Security/HostFirewall.php
  • Test: tests/Feature/Security/HostFirewallTest.php

Schnittstellen:

  • Verbraucht: App\Services\Ssh\RemoteShell (run(string): CommandResult), App\Models\Host

  • Liefert: HostFirewall::block(Host $host, string $ip, int $seconds): bool, HostFirewall::release(Host $host, string $ip): bool

  • Schritt 1: Den fehlschlagenden Test schreiben

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

use App\Models\Host;
use App\Provisioning\Steps\Host\SecureHostFirewall;
use App\Services\Security\HostFirewall;
use App\Services\Ssh\FakeRemoteShell;

it('stellt die Sperrregel UNTER die Regel fuer bestehende Verbindungen', function () {
    // Das ist die eigentliche Zusage des ganzen Systems: „wer drin ist, bleibt
    // drin". Sie hängt an dieser Reihenfolge und an nichts sonst. Geprüft am
    // ERZEUGTEN Regelwerk, nicht am Quelltext — ein Test gegen den Quelltext
    // wäre auch dann grün, wenn die Zeilen im Ergebnis anders herum stünden.
    $shell = new FakeRemoteShell;
    app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);

    app(SecureHostFirewall::class)->execute(
        \App\Models\ProvisioningRun::factory()->forHost(Host::factory()->create())->create()
    );

    $regelwerk = $shell->files()['/etc/nftables.conf'] ?? '';

    $established = strpos($regelwerk, 'ct state established,related accept');
    $sperre = strpos($regelwerk, '@clupilot_blocked');

    expect($established)->not->toBeFalse()
        ->and($sperre)->not->toBeFalse()
        ->and($established)->toBeLessThan($sperre);

    // Beide Mengen, und beide mit Ablaufzeit — ohne `flags timeout` nimmt
    // nftables die Zeitangabe beim Eintragen gar nicht an.
    expect($regelwerk)->toContain('set clupilot_blocked')
        ->and($regelwerk)->toContain('set clupilot_blocked6')
        ->and(substr_count($regelwerk, 'flags timeout'))->toBe(2);
});

it('traegt eine Adresse mit Ablaufzeit ein und nimmt sie wieder heraus', function () {
    $shell = new FakeRemoteShell;
    app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);
    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);

    app(HostFirewall::class)->block($host, '203.0.113.7', 3600);
    app(HostFirewall::class)->release($host, '203.0.113.7');

    expect($shell->ran('add element inet clupilot_filter clupilot_blocked { 203.0.113.7 timeout 3600s }'))->toBeTrue()
        ->and($shell->ran('delete element inet clupilot_filter clupilot_blocked { 203.0.113.7 }'))->toBeTrue();
});

it('waehlt fuer eine IPv6-Adresse die zweite Menge', function () {
    $shell = new FakeRemoteShell;
    app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);
    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);

    app(HostFirewall::class)->block($host, '2001:db8::1', 3600);

    expect($shell->ran('clupilot_blocked6 { 2001:db8::1 timeout 3600s }'))->toBeTrue();
});
  • Schritt 2: Test laufen lassen, Fehlschlag bestätigen

Ausführen: docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Security/HostFirewallTest.php Erwartet: FEHLER — die Mengen fehlen im Regelwerk

  • Schritt 3: Das Regelwerk erweitern

In SecureHostFirewall::renderNftablesConfig(), innerhalb von table inet clupilot_filter und vor der Kette:

    # Adressen, die gerade gesperrt sind. `flags timeout` ist nicht schmückend:
    # ohne das nimmt nftables beim Eintragen gar keine Zeitangabe an — und die
    # Frist liefe dann nur in unserer Datenbank ab, nicht im Kernel.
    set clupilot_blocked {
        type ipv4_addr
        flags timeout
    }

    set clupilot_blocked6 {
        type ipv6_addr
        flags timeout
    }

Und in der Kette input, unmittelbar nach ct state established,related accept:

        # UNTER der Zeile darüber, und das ist die ganze Zusage dieses Systems:
        # wer schon verbunden ist, bleibt verbunden. Gesperrt wird nur, was neu
        # anklopft. Stünde diese Regel eine Zeile höher, flöge jeder mitten aus
        # seiner Sitzung — auch der, den es gar nicht meint.
        ip  saddr @clupilot_blocked  drop
        ip6 saddr @clupilot_blocked6 drop
  • Schritt 4: Den Dienst anlegen

app/Services/Security/HostFirewall.php mit block() und release(). Die Adressfamilie entscheidet über die Menge (str_contains($ip, ':')). Beide Methoden geben false zurück, statt zu werfen, wenn der Host nicht erreichbar ist — die Sperre steht dann trotzdem in der Datenbank und wird beim nächsten Lauf erneut eingetragen (Aufgabe 4, Wiedereintragen).

Die Verbindung wird wie in HostStep::keyLogin() aufgebaut: connectWithKey($host->wg_ip, 'root', SecretVault ssh.private_key, $host->ssh_host_key).

  • Schritt 5: Test laufen lassen, Erfolg bestätigen

Ausführen: docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Security/HostFirewallTest.php Erwartet: BESTANDEN

  • Schritt 6: Volle Suite und Commit
docker compose exec -u 1000:1000 -T app php artisan test
git add app/Provisioning/Steps/Host/SecureHostFirewall.php app/Services/Security tests/Feature/Security
git commit -m "Sperrliste in der Host-Firewall, unter der Regel fuer bestehende Verbindungen"

Aufgabe 3: Der Datensatz und die Sperr-Entscheidung

Dateien:

  • Erstellen: database/migrations/2026_08_03_120000_clupilot_merkt_sich_gesperrte_adressen.php, app/Models/SecurityBlock.php, database/factories/SecurityBlockFactory.php, app/Services/Security/BlockAddress.php
  • Test: tests/Feature/Security/BlockAddressTest.php

Schnittstellen:

  • Verbraucht: HostFirewall::block() aus Aufgabe 2

  • Liefert: BlockAddress::forInstance(Instance $i, string $ip, int $attempts): ?SecurityBlock, BlockAddress::forHost(Host $h, string $ip, int $attempts): ?SecurityBlock, SecurityBlock::active() (Scope), SecurityBlock::release(?Model $by): void

  • Schritt 1: Den fehlschlagenden Test schreiben

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

use App\Models\Host;
use App\Models\Instance;
use App\Models\SecurityBlock;
use App\Services\Security\BlockAddress;
use Illuminate\Support\Carbon;

it('sperrt beim ersten Mal fuer eine Stunde', function () {
    $instance = Instance::factory()->create();

    $block = app(BlockAddress::class)->forInstance($instance, '203.0.113.7', 12);

    expect($block)->not->toBeNull()
        ->and($block->expires_at->diffInMinutes(now()))->toBeGreaterThan(55)
        ->and($block->expires_at->diffInMinutes(now()))->toBeLessThan(65)
        ->and($block->strikes)->toBe(1)
        ->and($block->attempts)->toBe(12);
});

it('verdoppelt bei Wiederholung und haelt bei 24 Stunden an', function () {
    $instance = Instance::factory()->create();
    $dienst = app(BlockAddress::class);

    $dauern = [];
    for ($i = 0; $i < 7; $i++) {
        $block = $dienst->forInstance($instance, '203.0.113.7', 10);
        $dauern[] = (int) round($block->blocked_at->diffInHours($block->expires_at));
        $block->release(null); // freigegeben, aber der Zähler bleibt
    }

    expect($dauern)->toBe([1, 2, 4, 8, 16, 24, 24]);
});

it('faengt nach 24 Stunden ohne Vorfall wieder bei einer Stunde an', function () {
    $instance = Instance::factory()->create();
    $dienst = app(BlockAddress::class);

    $dienst->forInstance($instance, '203.0.113.7', 10)->release(null);

    Carbon::setTestNow(now()->addHours(25));
    $zweiter = $dienst->forInstance($instance, '203.0.113.7', 10);

    expect($zweiter->strikes)->toBe(1);
});

it('sperrt NIEMALS eine Adresse aus dem Verwaltungsnetz', function () {
    // Eine Sperrliste, die sich selbst aussperren kann, ist eine Falle: über
    // genau dieses Netz erreicht CluPilot den Host.
    $host = Host::factory()->create();

    expect(app(BlockAddress::class)->forHost($host, '10.66.0.1', 999))->toBeNull()
        ->and(app(BlockAddress::class)->forHost($host, '127.0.0.1', 999))->toBeNull()
        ->and(app(BlockAddress::class)->forHost($host, '::1', 999))->toBeNull()
        ->and(SecurityBlock::count())->toBe(0);
});

it('haelt eine laufende Sperre nicht zweimal', function () {
    $instance = Instance::factory()->create();
    $dienst = app(BlockAddress::class);

    $dienst->forInstance($instance, '203.0.113.7', 10);

    expect($dienst->forInstance($instance, '203.0.113.7', 10))->toBeNull()
        ->and(SecurityBlock::count())->toBe(1);
});
  • Schritt 2: Test laufen lassen, Fehlschlag bestätigen

Ausführen: docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Security/BlockAddressTest.php Erwartet: FEHLER — Tabelle security_blocks fehlt

  • Schritt 3: Migration schreiben

security_blocks: id, uuid (unique), host_id (nullable, FK, nullOnDelete), instance_id (nullable, FK, cascadeOnDelete), ip (string 45), reason (string 32), attempts (unsigned int), strikes (unsigned tinyint, Vorgabe 1), blocked_at, expires_at, released_at (nullable), released_by_type/released_by_id (nullable, morph), Zeitstempel.

Index auf (instance_id, ip) und (host_id, ip), dazu expires_at — die Ansicht fragt „was gilt gerade".

Im selben Zug die zwei Cursor: instances.security_log_offset (unsignedBigInteger, Vorgabe 0) und hosts.security_log_seen_at (timestamp, nullable). Beide in die $fillable bzw. casts der Modelle nachtragen.

  • Schritt 4: Modell und Fabrik

SecurityBlock mit HasUuid, Beziehungen host(), instance(), releasedBy() (morphTo), Scope active() (released_at IS NULL AND expires_at > now()), Methode release(?Model $by) — setzt released_at, released_by, ruft HostFirewall::release() am zuständigen Host.

  • Schritt 5: Den Dienst anlegen

BlockAddress mit forInstance() und forHost(). Beide:

  1. Ausnahmeliste prüfen → null.
  2. Läuft schon eine aktive Sperre für dieses Subjekt und diese Adresse → null.
  3. strikes = Anzahl Sperren derselben Adresse am selben Subjekt in den letzten 24 Stunden + 1.
  4. Dauer = min(3600 * 2 ** ($strikes - 1), 86400).
  5. Datensatz anlegen, HostFirewall::block() am Host der Instanz bzw. am Host selbst.
  • Schritt 6: Test laufen lassen, Erfolg bestätigen

Ausführen: docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Security/BlockAddressTest.php Erwartet: BESTANDEN

  • Schritt 7: Volle Suite und Commit
docker compose exec -u 1000:1000 -T app php artisan test
git add database app/Models/SecurityBlock.php app/Services/Security tests/Feature/Security
git commit -m "Gesperrte Adressen: Datensatz, Verdopplung und die Liste, die nie gesperrt wird"

Aufgabe 4: Die Melder und der Zeitplan

Dateien:

  • Erstellen: app/Services/Security/FailedLoginReader.php, app/Provisioning/Jobs/ScanForIntrusions.php
  • Ändern: routes/console.php
  • Test: tests/Feature/Security/ScanForIntrusionsTest.php

Schnittstellen:

  • Verbraucht: BlockAddress aus Aufgabe 3, ProxmoxClient::guestExec(string $node, int $vmid, string $command): array, RemoteShell::run()

  • Liefert: FailedLoginReader::fromInstance(Instance): array{offset: int, addresses: array<string, int>}, FailedLoginReader::fromHost(Host): array{seenAt: Carbon, addresses: array<string, int>}

  • Schritt 1: Den fehlschlagenden Test schreiben

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

use App\Models\Instance;
use App\Models\SecurityBlock;
use App\Provisioning\Jobs\ScanForIntrusions;
use App\Services\Proxmox\FakeProxmoxClient;

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");
}

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);

    $instance = Instance::factory()->create(['status' => 'active', 'vmid' => 101]);
    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);

    Instance::factory()->create(['status' => 'active', 'vmid' => 101]);
    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);

    Instance::factory()->create(['status' => 'active', 'vmid' => 101]);
    app(ScanForIntrusions::class)->handle();

    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 = Instance::factory()->create(['status' => 'active', 'vmid' => 101, '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.
    $shell = new \App\Services\Ssh\FakeRemoteShell;
    app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);

    $block = SecurityBlock::factory()->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();
});

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 = Instance::factory()->create(['status' => 'active', 'vmid' => 101, 'security_log_offset' => 4711]);
    app(ScanForIntrusions::class)->handle();

    expect($instance->fresh()->security_log_offset)->toBe(4711);
});
  • Schritt 2: Test laufen lassen, Fehlschlag bestätigen

Ausführen: docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Security/ScanForIntrusionsTest.php Erwartet: FEHLER — ScanForIntrusions fehlt

  • Schritt 3: Den Leser anlegen

FailedLoginReader::fromInstance():

  1. Größe holen: guestExec($node, $vmid, 'cd '.NextcloudOcc::DIRECTORY.' && docker compose exec -T -u www-data app stat -c %s data/nextcloud.log'). Kleiner als der Versatz → Versatz auf 0.
  2. Neues lesen: … tail -c +<offset+1> data/nextcloud.log.
  3. Je Zeile json_decode; nur Zeilen behalten, deren message mit Login failed: beginnt; remoteAddr sammeln, Zeitpunkt aus time.
  4. Nur Einträge innerhalb der letzten 10 Minuten zählen.
  5. Neuen Versatz zurückgeben (alter + gelesene Bytes).

FailedLoginReader::fromHost(): journalctl -u ssh -u sshd --since <zuletzt> -o cat, Zeilen mit Failed password oder Invalid user, Adresse per Regex /from ([0-9a-fA-F:.]+) port/.

  • Schritt 4: Den Auftrag anlegen

ScanForIntrusions (implements ShouldQueue, public $queue = 'provisioning'):

  1. Über alle Instanzen mit vmid und Status active: lesen, zählen, ab 10 → BlockAddress::forInstance(); Versatz nur bei Erfolg speichern.
  2. Über alle Hosts mit wg_ip und ssh_host_key: dasselbe mit forHost().
  3. Danach alle SecurityBlock::active() erneut in die Firewall eintragen — mit now()->diffInSeconds($expires_at) als Ablaufzeit.
  • Schritt 5: In den Zeitplan

In routes/console.php neben den bestehenden Einträgen:

// Jede Minute: ein Angriff, der zehn Minuten läuft, soll nicht zehn Minuten
// unbemerkt laufen. Der Auftrag ist billig, wenn nichts zu tun ist — er liest
// nur den Zuwachs seit dem letzten Mal.
Schedule::job(new ScanForIntrusions)->everyMinute()->withoutOverlapping();
  • Schritt 6: Test laufen lassen, Erfolg bestätigen

Ausführen: docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Security/ScanForIntrusionsTest.php Erwartet: BESTANDEN

  • Schritt 7: Volle Suite und Commit
docker compose exec -u 1000:1000 -T app php artisan test
git add app/Services/Security app/Provisioning/Jobs/ScanForIntrusions.php routes/console.php tests/Feature/Security
git commit -m "Melder: gescheiterte Anmeldungen lesen, zaehlen, sperren"

Aufgabe 5: Die Benachrichtigung

Dateien:

  • Erstellen: app/Mail/SecurityBlockMail.php, resources/views/mail/security-block.blade.php, lang/de/security.php, lang/en/security.php
  • Ändern: app/Services/Mail/MailCatalogue.php (Eintrag security-block), app/Services/Mail/MailPreviews.php (Vorschau-Fall), app/Services/Security/BlockAddress.php (verschicken)
  • Test: tests/Feature/Security/SecurityBlockMailTest.php

Schnittstellen:

  • Verbraucht: MailCatalogue/MailRoute aus Aufgabe 1, SecurityBlock aus Aufgabe 3

  • Schritt 1: Den fehlschlagenden Test schreiben

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

use App\Mail\SecurityBlockMail;
use App\Models\Instance;
use App\Services\Security\BlockAddress;
use Illuminate\Support\Facades\Mail;

it('schickt dem Inhaber eine Mail, wenn seine Instanz gesperrt wird', function () {
    Mail::fake();
    $instance = Instance::factory()->create(['status' => 'active', 'vmid' => 101]);

    app(BlockAddress::class)->forInstance($instance, '203.0.113.7', 12);

    Mail::assertQueued(SecurityBlockMail::class, fn ($m) => $m->hasTo($instance->customer->email));
});

it('schickt hoechstens eine Mail je Instanz und Stunde', function () {
    // Ein Angreifer, der Adressen durchwechselt, erzeugt sonst zwanzig Mails —
    // und die zwanzigste liest niemand mehr.
    Mail::fake();
    $instance = Instance::factory()->create(['status' => 'active', 'vmid' => 101]);

    app(BlockAddress::class)->forInstance($instance, '203.0.113.7', 12);
    app(BlockAddress::class)->forInstance($instance, '203.0.113.8', 12);
    app(BlockAddress::class)->forInstance($instance, '203.0.113.9', 12);

    Mail::assertQueuedCount(1);
});

it('laesst die Sperre stehen, wenn die Mail scheitert', function () {
    // Zustellung ist nicht die Bedingung fuer Schutz. Eine Sperre, die von einem
    // kaputten Postfach abhinge, waere genau dann weg, wenn ohnehin schon etwas
    // im Argen liegt.
    Mail::shouldReceive('to')->andThrow(new RuntimeException('Postfach kaputt'));

    $instance = Instance::factory()->create(['status' => 'active', 'vmid' => 101]);
    $block = app(BlockAddress::class)->forInstance($instance, '203.0.113.7', 12);

    expect($block)->not->toBeNull()
        ->and(SecurityBlock::count())->toBe(1);
});

it('geht standardmaessig aus dem System-Postfach und folgt der Wegwahl', function () {
    \App\Models\Mailbox::factory()->create(['key' => 'no-reply', 'active' => true]);
    $info = \App\Models\Mailbox::factory()->create(['key' => 'info', 'active' => true]);
    \App\Support\Settings::set(\App\Services\Mail\MailPurpose::settingKey(\App\Services\Mail\MailPurpose::SYSTEM), 'no-reply');

    $block = \App\Models\SecurityBlock::factory()->create();

    expect((new SecurityBlockMail($block))->envelope()->from->address)->toBe('no-reply@clupilot.com');

    \App\Support\Settings::set(\App\Services\Mail\MailRoute::settingKey('security-block'), 'info');

    expect((new SecurityBlockMail($block))->envelope()->from->address)->toBe($info->address);
});
  • Schritt 2: Test laufen lassen, Fehlschlag bestätigen

Ausführen: docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Security/SecurityBlockMailTest.php Erwartet: FEHLER — SecurityBlockMail fehlt

  • Schritt 3: Die Mail anlegen

Nach dem Muster von NewDeviceSignInMail: SendsFromMailbox, mailboxEnvelope(MailPurpose::SYSTEM, __('security.mail_subject'), 'security-block'). Der Text nennt Adresse, Zeitpunkt, Anzahl der Versuche, wann die Sperre von selbst abläuft — und verlinkt auf die Portalseite. Eine Warnung, deren einziger Rat „handeln Sie" ist, ist eine Warnung ohne Handgriff.

  • Schritt 4: Die Drossel in BlockAddress

Nach dem Anlegen: verschicken, wenn für dieses Subjekt in der letzten Stunde keine Mail verschickt wurde. Der Zeitpunkt steht in Settings unter security.notified.instance.<id> — kein neues Feld für etwas, das nach einer Stunde niemanden mehr interessiert.

  • Schritt 5: In den Katalog

'security-block' => ['label' => 'Adresse wegen Anmeldeversuchen gesperrt', 'purpose' => MailPurpose::SYSTEM], plus ein Vorschau-Fall in MailPreviews.

  • Schritt 6: Test laufen lassen, Erfolg bestätigen

Ausführen: docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Security/SecurityBlockMailTest.php Erwartet: BESTANDEN

  • Schritt 7: Volle Suite und Commit
docker compose exec -u 1000:1000 -T app php artisan test
git add app/Mail app/Services resources/views/mail lang tests/Feature/Security
git commit -m "Benachrichtigung ueber eine gesperrte Adresse, hoechstens eine je Stunde"

Aufgabe 6: Die Ansichten

Dateien:

  • Erstellen: app/Livewire/Security.php, resources/views/livewire/security.blade.php, app/Livewire/ConfirmReleaseBlock.php, resources/views/livewire/confirm-release-block.blade.php

  • Ändern: routes/web.php, app/Support/Navigation.php, resources/views/livewire/admin/instance-detail.blade.php, resources/views/livewire/admin/host-detail.blade.php, app/Livewire/Admin/Overview.php

  • Test: tests/Feature/Security/SecurityPageTest.php

  • Schritt 1: Den fehlschlagenden Test schreiben

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

use App\Livewire\Security;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\SecurityBlock;
use Livewire\Livewire;

it('zeigt dem Inhaber die Sperren seiner eigenen Instanz', function () {
    $customer = Customer::factory()->create();
    $instance = Instance::factory()->for($customer)->create();
    $block = SecurityBlock::factory()->for($instance)->create(['ip' => '203.0.113.7']);

    Livewire::actingAs($customer->user)->test(Security::class)->assertSee('203.0.113.7');
});

it('zeigt einem Inhaber die Sperren eines FREMDEN Kunden nicht', function () {
    $meine = Customer::factory()->create();
    $fremde = Instance::factory()->create();
    SecurityBlock::factory()->for($fremde)->create(['ip' => '198.51.100.9']);

    Livewire::actingAs($meine->user)->test(Security::class)->assertDontSee('198.51.100.9');
});

it('laesst einen Inhaber eine fremde Sperre nicht aufheben', function () {
    $meine = Customer::factory()->create();
    $fremd = SecurityBlock::factory()->for(Instance::factory()->create())->create();

    Livewire::actingAs($meine->user)->test(Security::class)
        ->call('onReleaseConfirmed', $fremd->uuid)
        ->assertForbidden();

    expect($fremd->fresh()->released_at)->toBeNull();
});

it('hebt eine eigene Sperre auf und traegt ein, wer es war', function () {
    $customer = Customer::factory()->create();
    $instance = Instance::factory()->for($customer)->create();
    $block = SecurityBlock::factory()->for($instance)->create();

    Livewire::actingAs($customer->user)->test(Security::class)
        ->call('onReleaseConfirmed', $block->uuid);

    expect($block->fresh()->released_at)->not->toBeNull()
        ->and($block->fresh()->released_by_id)->toBe($customer->user->id);
});
  • Schritt 2: Test laufen lassen, Fehlschlag bestätigen

Ausführen: docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Security/SecurityPageTest.php Erwartet: FEHLER — App\Livewire\Security fehlt

  • Schritt 3: Portalseite und Bestätigung

Security mit ResolvesCustomer (wie die übrigen Portalseiten), Liste der aktiven und der abgelaufenen Sperren der eigenen Instanzen. Der Knopf öffnet ConfirmReleaseBlock per $dispatch('openModal', …)nie wire:confirm (R23). Das Modal mutiert nichts; es wirft block-release-confirmed, das die Seite per #[On] auffängt (Muster wie ConfirmEndOtherSessions).

onReleaseConfirmed() prüft selbst, dass die Sperre zu einer Instanz dieses Kunden gehört, und wirft sonst 403 — ein Modal ist ohne die Middleware der Seite erreichbar (siehe R20).

Route und Navigationseintrag „Sicherheit" ins Portal.

  • Schritt 4: Konsole

Auf der Instanz-Detailseite und der Host-Detailseite je ein Abschnitt mit denselben Zeilen und demselben Knopf, gesperrt hinter @can('instances.manage') bzw. @can('hosts.manage'). In Overview ein Hinweis, solange irgendwo eine Sperre aktiv ist — nach dem Muster des bestehenden monitoring_down-Hinweises.

  • Schritt 5: Test laufen lassen, Erfolg bestätigen

Ausführen: docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Security/SecurityPageTest.php Erwartet: BESTANDEN

  • Schritt 6: Bauen, volle Suite, Commit
docker compose exec -u 1000:1000 -e npm_config_cache=/tmp/npm-cache -T app npm run build
docker compose exec -u 1000:1000 -T app php artisan test
git add app/Livewire resources/views routes app/Support/Navigation.php tests/Feature/Security
git commit -m "Sperren sehen und aufheben: Portalseite fuer den Inhaber, Abschnitte in der Konsole"

Nach dem Bauen

  • Sichtprüfung durch den Controller, nicht durch den Implementierer: Portalseite und Konsolenabschnitte im Browser ansehen. Eine Sperre lässt sich dafür mit der Fabrik anlegen, ohne einen Angriff zu inszenieren.
  • Die Firewall-Regel am echten Host lässt sich hier nicht führen — auf der Entwicklungsmaschine gibt es keinen. Das ist eine ehrliche Lücke und gehört in den Bericht, nicht wegerklärt.

Was dieser Plan bewusst NICHT tut

  • Postfächer anlegen, ändern, löschen. Eigenes Projekt (Spec-Abschnitt 3a).
  • Mitarbeiterverwaltung im Portal. Eigenes Projekt.
  • Nextclouds eigene Bremse anfassen. Sie bleibt, wie sie ist.