Plan: Fruehwarnsystem, sechs Aufgaben
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details

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 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-03 12:27:49 +02:00
parent cbb0747732
commit 850a9ebd5b
1 changed files with 877 additions and 0 deletions

View File

@ -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 **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`.
- 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
<?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');
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
<?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
<?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`:
```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 `<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**
```bash
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
<?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**
```bash
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
<?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**
```bash
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
<?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()->active()->create();
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()->active()->create();
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()->active()->create();
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()->active()->create(['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()->active()->create(['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:
```php
// 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**
```bash
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
<?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()->active()->create();
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()->active()->create();
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()->active()->create();
$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**
```bash
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
<?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**
```bash
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.