41 KiB
Frühwarnsystem — Umsetzungsplan
Für agentische Arbeiter: ERFORDERLICHE UNTER-SKILL:
superpowers:subagent-driven-development(empfohlen) odersuperpowers: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_ENDPOINTohne 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.mdgelten und werden per Test erzwungen. Besonders: R20 (Bearbeiten im Modal), R23 (Bestätigen im Modal, niewire:confirm), R24.3 (keine Blade-Direktive in der Attributliste eines Komponenten-Tags). - Jeder Befehl im
app-Container nennt seinen Benutzer (-u www-databzw.-u root) — erzwungen durchtests/Feature/DeploymentRunsAsTheAppUserTest.php. InstanceFactorykennt keinenactive()-Zustand — Status undvmidwerden 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 testdocker 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, umsecurity-blockeinzutragen. -
Schritt 1: Den fehlschlagenden Test schreiben
<?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
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
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:
SendsFromMailboxdie 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:
return $this->mailboxEnvelope(MailPurpose::SYSTEM, __('devices.mail_subject'), 'new-device');
- Schritt 6:
MailPreviewsaus 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
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 // 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
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 // 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:
- Ausnahmeliste prüfen →
null. - Läuft schon eine aktive Sperre für dieses Subjekt und diese Adresse →
null. strikes= Anzahl Sperren derselben Adresse am selben Subjekt in den letzten 24 Stunden + 1.- Dauer =
min(3600 * 2 ** ($strikes - 1), 86400). - 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
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:
BlockAddressaus 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 // 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():
- 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. - Neues lesen:
… tail -c +<offset+1> data/nextcloud.log. - Je Zeile
json_decode; nur Zeilen behalten, derenmessagemitLogin failed:beginnt;remoteAddrsammeln, Zeitpunkt austime. - Nur Einträge innerhalb der letzten 10 Minuten zählen.
- 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'):
- Über alle Instanzen mit
vmidund Statusactive: lesen, zählen, ab 10 →BlockAddress::forInstance(); Versatz nur bei Erfolg speichern. - Über alle Hosts mit
wg_ipundssh_host_key: dasselbe mitforHost(). - Danach alle
SecurityBlock::active()erneut in die Firewall eintragen — mitnow()->diffInSeconds($expires_at)als Ablaufzeit.
- Schritt 5: In den Zeitplan
In routes/console.php neben den bestehenden Einträgen:
// 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
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(Eintragsecurity-block),app/Services/Mail/MailPreviews.php(Vorschau-Fall),app/Services/Security/BlockAddress.php(verschicken) - Test:
tests/Feature/Security/SecurityBlockMailTest.php
Schnittstellen:
-
Verbraucht:
MailCatalogue/MailRouteaus Aufgabe 1,SecurityBlockaus Aufgabe 3 -
Schritt 1: Den fehlschlagenden Test schreiben
<?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
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 // 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
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.