From 850a9ebd5b834970ae4ed3edd3ba8177c7061a99 Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 3 Aug 2026 12:27:49 +0200 Subject: [PATCH] Plan: Fruehwarnsystem, sechs Aufgaben Reihenfolge nach Abhaengigkeit: Wegwahl je Mailart (steht fuer sich), dann die Sperrliste in der Firewall, darauf der Datensatz mit Verdopplung und Ausnahmeliste, darauf die Melder, dann die Mail, zuletzt die Ansichten. Zwei Zusagen haengen an genau einer Zeile und bekommen deshalb einen Test, der das ERGEBNIS prueft und nicht den Quelltext: die Sperrregel muss unter 'ct state established,related accept' stehen (sonst fliegt jeder mitten aus seiner Sitzung), und beim Wiedereintragen nach einem Neustart muss die RESTlaufzeit gesetzt werden (sonst verlaengert sich eine Sperre bei jedem Neustart des Hosts). Beim Selbstdurchgang gegen die Spec fielen zwei fehlende Pruefungen auf und sind nachgetragen: Versuche ueber zwei Fenster verteilt duerfen nicht sperren, und eine gescheiterte Mail darf die Sperre nicht mitnehmen. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-03-fruehwarnsystem.md | 877 ++++++++++++++++++ 1 file changed, 877 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-fruehwarnsystem.md 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 `