# 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 **R18–R24** 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`, `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 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 */ 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 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`: ```php 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 `