diff --git a/docs/superpowers/plans/2026-08-03-fruehwarnsystem.md b/docs/superpowers/plans/2026-08-03-fruehwarnsystem.md new file mode 100644 index 0000000..f2b2c5a --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-fruehwarnsystem.md @@ -0,0 +1,877 @@ +# 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`. +- 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'); + + expect(MailRoute::purposeOrMailbox('new-device', MailPurpose::SYSTEM)?->id)->toBe($info->id) + // Und nur diese eine: der Nachbar bleibt, wo er war. + ->and(MailRoute::purposeOrMailbox('invoice', MailPurpose::BILLING)?->key)->not->toBe('info'); +}); + +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 `