884 lines
41 KiB
Markdown
884 lines
41 KiB
Markdown
# 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.
|