diff --git a/app/Services/Security/BlockAddress.php b/app/Services/Security/BlockAddress.php index 13483a3..54f9cd7 100644 --- a/app/Services/Security/BlockAddress.php +++ b/app/Services/Security/BlockAddress.php @@ -5,6 +5,7 @@ namespace App\Services\Security; use App\Mail\SecurityBlockMail; use App\Models\Host; use App\Models\Instance; +use App\Models\Operator; use App\Models\SecurityBlock; use App\Support\ProvisioningSettings; use App\Support\Settings; @@ -63,7 +64,7 @@ class BlockAddress return null; } - return $this->createBlock( + $block = $this->createBlock( ip: $ip, attempts: $attempts, reason: 'host_ssh', @@ -71,6 +72,10 @@ class BlockAddress hostId: $host->id, firewallHost: $host, ); + + $this->notifyHostManagers($host, $block); + + return $block; } /** @@ -80,10 +85,8 @@ class BlockAddress * eigenen Spalte: nach einer Stunde interessiert er niemanden mehr, und * eine Spalte dafür wäre außerhalb dieses einen Fensters immer bedeutungslos. * - * Für Host-Sperren gibt es hier absichtlich noch kein Gegenstück: im Repo - * findet sich kein Muster, wie eine Betreiber-Meldung ihren Empfänger - * sucht (keine Konsolen-Rolle "bekommt Systemmails", keine Adresse dafür - * in den Einstellungen) — das wäre eine eigene Entwurfsentscheidung. + * Für Host-Sperren siehe `notifyHostManagers()` weiter unten — andere + * Empfängerlogik (Betreiber statt Kunde), gleiche Drossel-Idee. * * Zustellung ist nicht die Bedingung für Schutz: die Sperre steht bereits, * bevor hier auch nur versucht wird zu verschicken, und die Drossel wird @@ -112,6 +115,55 @@ class BlockAddress } } + /** + * Benachrichtigt jeden AKTIVEN Betreiber mit `hosts.manage` — höchstens + * einmal je Host und Stunde (`security.notified.host.`, dieselbe + * Drossel-Idee wie bei Instanzen, eigener Schlüssel). + * + * Nicht an eine feste Adresse und nicht an eine neue Einstellung: genau + * diese Berechtigung entscheidet schon, wer eine Host-Sperre in der + * Konsole überhaupt aufheben darf (siehe `HostDetail`, `HostTerminal`, + * `ConfirmRemoveHost`, alle hinter `hosts.manage`) — eine Mail an jemand + * anderen wäre Lärm, eine an jeden mit dieser Berechtigung ist per + * Definition der richtige Empfängerkreis, und er pflegt sich von selbst + * bei jedem Rollenwechsel mit (Koordinator-Entscheidung, siehe Bericht). + * + * Kein Empfänger ist kein Fehler: eine frische Installation ohne Admin + * neben dem Owner, oder eine, in der `hosts.manage` gerade niemandem + * zugewiesen ist, bekommt schlicht keine Mail — die Sperre selbst steht + * trotzdem, unabhängig davon. + */ + private function notifyHostManagers(Host $host, SecurityBlock $block): void + { + $recipients = Operator::query() + ->whereNull('disabled_at') + ->permission('hosts.manage') + ->pluck('email'); + + if ($recipients->isEmpty()) { + return; + } + + $key = 'security.notified.host.'.$host->id; + $last = Settings::get($key); + + if ($last !== null && now()->subHour()->lt($last)) { + return; + } + + Settings::set($key, now()->toIso8601String()); + + // Einzeln umschlossen: ein Postfach, das ablehnt, darf die übrigen + // Betreiber nicht auch noch um ihre Meldung bringen. + foreach ($recipients as $email) { + try { + Mail::to($email)->queue(new SecurityBlockMail($block)); + } catch (Throwable $e) { + report($e); + } + } + } + /** Läuft für diese Adresse an diesem Subjekt schon eine Sperre? */ private function hasActiveBlock(string $ip, ?int $instanceId, ?int $hostId): bool { diff --git a/tests/Feature/Security/SecurityBlockMailTest.php b/tests/Feature/Security/SecurityBlockMailTest.php index 86518f6..a3fbe65 100644 --- a/tests/Feature/Security/SecurityBlockMailTest.php +++ b/tests/Feature/Security/SecurityBlockMailTest.php @@ -3,6 +3,7 @@ // tests/Feature/Security/SecurityBlockMailTest.php use App\Mail\SecurityBlockMail; +use App\Models\Host; use App\Models\Instance; use App\Models\Mailbox; use App\Models\SecurityBlock; @@ -67,3 +68,30 @@ it('geht standardmaessig aus dem System-Postfach und folgt der Wegwahl', functio expect((new SecurityBlockMail($block))->envelope()->from->address)->toBe($info->address); }); + +// ---- Host-Sperren: an jeden aktiven Betreiber mit hosts.manage (Koordinator-Entscheidung, Fix-Runde 1) ---- + +it('schickt bei einer Host-Sperre an jeden Betreiber, der Hosts verwalten darf', function () { + Mail::fake(); + // Owner und Admin haben hosts.manage (seed_roles_and_permissions), Support + // nicht — siehe database/migrations/2026_07_25_133900_seed_roles_and_permissions.php. + $darf = operator('Admin'); + $darfNicht = operator('Support'); + $host = Host::factory()->create(); + + app(BlockAddress::class)->forHost($host, '203.0.113.7', 12); + + // Beide Haelften: der Zustaendige bekommt sie, der Unzustaendige nicht. + Mail::assertQueued(SecurityBlockMail::class, fn ($m) => $m->hasTo($darf->email)); + Mail::assertNotQueued(SecurityBlockMail::class, fn ($m) => $m->hasTo($darfNicht->email)); +}); + +it('sperrt auch dann, wenn es keinen zustaendigen Betreiber gibt', function () { + Mail::fake(); + // Keine Betreiber mit hosts.manage — kein Empfaenger, kein Fehler, aber + // sehr wohl eine Sperre. Schutz haengt nicht an Zustellung. + $host = Host::factory()->create(); + + expect(app(BlockAddress::class)->forHost($host, '203.0.113.7', 12))->not->toBeNull(); + Mail::assertNothingQueued(); +});