onQueue('provisioning'); } /** * Ohne Methodeninjektion: `Schedule::job(new ScanForIntrusions)` (wie jeder * andere Auftrag in routes/console.php) erzeugt die Instanz direkt statt * über den Container, und `app(ScanForIntrusions::class)->handle()` ruft * `handle()` als gewöhnlichen Methodenaufruf auf — beides läuft ohne * `Container::call()`, das typisierte Parameter sonst auflöst. Also holt * sich `handle()` seine Abhängigkeiten selbst. */ public function handle(): void { $reader = app(FailedLoginReader::class); $blocker = app(BlockAddress::class); $firewall = app(HostFirewall::class); $this->scanInstances($reader, $blocker); $this->scanHosts($reader, $blocker); $this->reapplyActiveBlocks($firewall); } private function scanInstances(FailedLoginReader $reader, BlockAddress $blocker): void { $instances = Instance::query() ->where('status', 'active') ->whereNotNull('vmid') ->get(); foreach ($instances as $instance) { // Wie PingHosts nebenan: je Instanz einzeln umschlossen, nicht die // ganze Schleife in einem Block. `FailedLoginReader::fromInstance()` // fängt zwar schon jede Ausnahme von `guestExec()` selbst ab // (Fix-Runde 2: es tat das vorher NICHT, und eine einzelne // abgeschaltete VM riss den ganzen Lauf mit — die übrigen // Instanzen wurden nicht mehr gelesen, scanHosts() lief nicht, // und vor allem reapplyActiveBlocks() lief nicht, jede Minute // erneut). Dieser zweite Ring bleibt als Netz für alles andere in // dieser Runde (BlockAddress, save()), aus demselben Grund. try { $result = $reader->fromInstance($instance); // Gast antwortet nicht — kein Vorfall, Versatz bleibt unangetastet. if ($result === null) { continue; } $subject = 'instance:'.$instance->id; foreach ($result['addresses'] as $ip => $freshAttempts) { $total = $this->accumulate($subject, $ip, $freshAttempts); if ($total >= self::THRESHOLD) { $blocker->forInstance($instance, $ip, $total); $this->resetAccumulator($subject, $ip); } } // Nur bei Erfolg speichern — genau die Bytes, die wirklich // gelesen wurden. $instance->forceFill(['security_log_offset' => $result['offset']])->save(); } catch (Throwable) { // Absichtlich still, wie PingHosts: der Cursor bleibt // unangetastet, der nächste Lauf versucht es wieder. } } } private function scanHosts(FailedLoginReader $reader, BlockAddress $blocker): void { $hosts = Host::query() ->whereNotNull('wg_ip') ->whereNotNull('ssh_host_key') ->get(); foreach ($hosts as $host) { $result = $reader->fromHost($host); if ($result === null) { continue; } $subject = 'host:'.$host->id; foreach ($result['addresses'] as $ip => $freshAttempts) { $total = $this->accumulate($subject, $ip, $freshAttempts); if ($total >= self::THRESHOLD) { $blocker->forHost($host, $ip, $total); $this->resetAccumulator($subject, $ip); } } $host->forceFill(['security_log_seen_at' => $result['seenAt']])->save(); } } /** * Addiert frisch gelesene Treffer auf den laufübergreifenden Stand und * gibt den neuen Gesamtstand zurück. Der Schlüssel trägt Subjekt UND * Adresse: zwei Instanzen, die von derselben Adresse Versuche sehen, * dürfen sich nicht gegenseitig hochzählen, und eine Adresse darf nicht * von den Versuchen einer anderen mitgerissen werden. */ private function accumulate(string $subject, string $ip, int $freshAttempts): int { return RateLimiter::increment($this->tallyKey($subject, $ip), self::WINDOW_SECONDS, $freshAttempts); } /** * Nach dem Sperren zurücksetzen. Ohne das stünde der Stand weiter bei der * Schwelle, und sobald die Sperre abläuft oder der Inhaber sie aufhebt, * löste der nächste einzelne Fehlversuch sofort die nächste Sperre aus. */ private function resetAccumulator(string $subject, string $ip): void { RateLimiter::clear($this->tallyKey($subject, $ip)); } private function tallyKey(string $subject, string $ip): string { return 'intrusion-scan:'.$subject.':'.$ip; } private function reapplyActiveBlocks(HostFirewall $firewall): void { $blocks = SecurityBlock::active()->with(['host', 'instance.host'])->get(); foreach ($blocks as $block) { $host = $block->host ?? $block->instance?->host; // Instanz noch nicht platziert, oder ihr Host inzwischen weg — // nichts, wo einzutragen wäre. if ($host === null) { continue; } // Gerundet, nicht abgeschnitten: Carbon 3 liefert diffInSeconds als // Fliesskommazahl, und zwischen dem Anlegen der Sperre und diesem // Lauf vergehen immer ein paar Millisekunden — ein bloßes (int) // wuerde JEDE Restlaufzeit um bis zu eine Sekunde verkuerzen. $seconds = (int) round(now()->diffInSeconds($block->expires_at)); if ($seconds > 0) { $firewall->block($host, $block->ip, $seconds); } } } }