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