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; } return $this->createBlock( ip: $ip, attempts: $attempts, reason: 'host_ssh', instanceId: null, hostId: $host->id, firewallHost: $host, ); } /** * 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 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. * * 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); } } /** 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; } }