isExempt($ip) || $this->hasActiveBlock($ip, instanceId: $instance->id, hostId: null)) { return null; } $block = $this->createBlock( ip: $ip, attempts: $attempts, reason: 'instance_login', instanceId: $instance->id, hostId: null, firewallHost: $instance->host, ); $this->notifyInstanceOwner($instance, $block); return $block; } public function forHost(Host $host, string $ip, int $attempts): ?SecurityBlock { if ($this->isExempt($ip) || $this->hasActiveBlock($ip, instanceId: null, hostId: $host->id)) { return null; } $block = $this->createBlock( ip: $ip, attempts: $attempts, reason: 'host_ssh', instanceId: null, hostId: $host->id, firewallHost: $host, ); $this->notifyHostManagers($host, $block); return $block; } /** * Benachrichtigt den Inhaber der Instanz per Mail — höchstens einmal je * Instanz und Stunde. Der Zeitpunkt der letzten Meldung steht in * `Settings` unter `security.notified.instance.`, nicht in einer * 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 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 * VOR dem Versandversuch gesetzt, nicht danach — sonst würde ein * dauerhaft kaputtes Postfach bei jeder weiteren Sperre an derselben * Instanz erneut versuchen und denselben Fehler immer wieder melden. Ein * Throwable beim Verschicken wird über `report()` gemeldet und * verschluckt: ein kaputtes Postfach darf die Sperre nicht rückgängig * machen, die es eigentlich ankündigen sollte. */ private function notifyInstanceOwner(Instance $instance, SecurityBlock $block): void { $key = 'security.notified.instance.'.$instance->id; $last = Settings::get($key); if ($last !== null && now()->subHour()->lt($last)) { return; } Settings::set($key, now()->toIso8601String()); try { Mail::to($instance->customer->email)->queue(new SecurityBlockMail($block)); } catch (Throwable $e) { report($e); } } /** * 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 { return SecurityBlock::query() ->where('ip', $ip) ->when($instanceId !== null, fn ($q) => $q->where('instance_id', $instanceId)) ->when($hostId !== null, fn ($q) => $q->where('host_id', $hostId)) ->active() ->exists(); } private function createBlock( string $ip, int $attempts, string $reason, ?int $instanceId, ?int $hostId, ?Host $firewallHost, ): SecurityBlock { $strikes = $this->strikesWithinADay($ip, $instanceId, $hostId) + 1; $seconds = min(self::BASE_SECONDS * 2 ** ($strikes - 1), self::MAX_SECONDS); $blockedAt = now(); $block = SecurityBlock::create([ 'instance_id' => $instanceId, 'host_id' => $hostId, 'ip' => $ip, 'reason' => $reason, 'attempts' => $attempts, 'strikes' => $strikes, 'blocked_at' => $blockedAt, 'expires_at' => $blockedAt->copy()->addSeconds($seconds), ]); // Ohne Host (Instanz noch nicht platziert) gibt es nichts einzutragen — // der Datensatz steht trotzdem, und eine spätere Zuweisung findet ihn. if ($firewallHost !== null) { $this->firewall->block($firewallHost, $ip, $seconds); } return $block; } /** * Die wievielte Sperre dieser Adresse an diesem Subjekt in den letzten 24 * Stunden das hier wird. Zählt JEDE Sperre in dem Fenster, auch eine * inzwischen vorzeitig aufgehobene — wer freigibt, hebt die Sperre auf, * nicht die Erinnerung daran, dass sie fällig war. */ private function strikesWithinADay(string $ip, ?int $instanceId, ?int $hostId): int { return SecurityBlock::query() ->where('ip', $ip) ->when($instanceId !== null, fn ($q) => $q->where('instance_id', $instanceId)) ->when($hostId !== null, fn ($q) => $q->where('host_id', $hostId)) ->where('blocked_at', '>=', now()->subDay()) ->count(); } private function isExempt(string $ip): bool { return $ip !== '' && IpUtils::checkIp($ip, $this->exemptRanges()); } /** * Die Ausnahmeliste. Hart verdrahtet, ohne Schalter: über das * Verwaltungsnetz `10.66.0.0/24` erreicht CluPilot den Host überhaupt — * eine Sperre dort wäre das Ende der Fernwartung. * * @return array */ private function exemptRanges(): array { $ranges = ['10.66.0.0/24', '127.0.0.1', '::1']; $endpoint = ProvisioningSettings::wgEndpoint(); // Leer ist kein Fehler (frische Installation ohne Endpoint) — dann // fällt genau dieser eine Eintrag der Liste weg. if ($endpoint !== '') { $ranges[] = Str::beforeLast($endpoint, ':'); } return $ranges; } }