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

    // 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
<?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()->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:

```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()->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**

```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.
