}|null */ public function fromInstance(Instance $instance): ?array { $host = $instance->host; // Kein Host, keine Maschine — nichts, was der Gastagent fragen könnte. if ($host === null || $instance->vmid === null) { return null; } $pve = $this->pve->forHost($host); $node = (string) ($host->node ?? 'pve'); $vmid = (int) $instance->vmid; $offset = (int) $instance->security_log_offset; $size = $pve->guestExec($node, $vmid, NextcloudOcc::exec('stat -c %s data/nextcloud.log')); if ((int) ($size['exitcode'] ?? 1) !== 0) { return null; } // Kleiner als der gemerkte Versatz heißt: rotiert. Ohne diese // Behandlung liest der nächste Aufruf ins Leere und sieht nie wieder // etwas — siehe den eigenen Testfall dafür. if ((int) trim((string) ($size['out-data'] ?? '')) < $offset) { $offset = 0; } $tail = $pve->guestExec($node, $vmid, NextcloudOcc::exec('tail -c +'.($offset + 1).' data/nextcloud.log')); if ((int) ($tail['exitcode'] ?? 1) !== 0) { return null; } $out = (string) ($tail['out-data'] ?? ''); return [ 'offset' => $offset + strlen($out), 'addresses' => $this->countWithinWindow($this->parseNextcloudLines($out)), ]; } /** * Die fehlgeschlagenen SSH-Anmeldungen seit dem gemerkten Zeitpunkt. * * Verbindet direkt über die WireGuard-Adresse, nicht über * `HostStep::keyLogin()`s Rückfallpfad auf die öffentliche Adresse — wie * `HostFirewall`: dieser Melder läuft gegen Hosts, die den Tunnel schon * haben, nie während der Inbetriebnahme selbst. * * @return array{seenAt: Carbon, addresses: array}|null */ public function fromHost(Host $host): ?array { // Ohne einen gemerkten Zeitpunkt nur das Fenster selbst lesen, statt // journalctl die ganze Historie des Hosts durchsuchen zu lassen. $since = ($host->security_log_seen_at ?? now()->subMinutes(self::WINDOW_MINUTES)) ->format('Y-m-d H:i:s'); try { $this->shell->connectWithKey( $host->wg_ip, 'root', (string) app(SecretVault::class)->get('ssh.private_key'), $host->ssh_host_key, // gepinnt bei EstablishSshTrust ); $result = $this->shell->run( 'journalctl -u ssh -u sshd --since '.escapeshellarg($since).' -o cat' ); } catch (Throwable) { // Nicht erreichbar — kein Vorfall, kein neuer Zeitpunkt. return null; } if (! $result->ok()) { return null; } return [ 'seenAt' => now(), 'addresses' => $this->countSshFailures($result->stdout), ]; } /** * Nur Zeilen, deren `message` mit "Login failed:" beginnt — Nextclouds * eigenes Format, stabil über Versionen hinweg (anders als * `occ security:bruteforce:*`, dessen Unterbefehle gewechselt haben). * * @return array */ private function parseNextcloudLines(string $out): array { $entries = []; foreach (preg_split('/\R/', trim($out)) ?: [] as $line) { if ($line === '') { continue; } $decoded = json_decode($line, true); if (! is_array($decoded) || ! str_starts_with((string) ($decoded['message'] ?? ''), 'Login failed:')) { continue; } $ip = (string) ($decoded['remoteAddr'] ?? ''); if ($ip === '' || ! isset($decoded['time'])) { continue; } $entries[] = ['ip' => $ip, 'time' => Carbon::parse($decoded['time'])]; } return $entries; } /** * Nur, was höchstens WINDOW_MINUTES alt ist, zählt gegen die Schwelle — * zehn Versuche über zwei Fenster verteilt sind eben keine zehn. * * @param array $entries * @return array */ private function countWithinWindow(array $entries): array { $cutoff = now()->subMinutes(self::WINDOW_MINUTES); $counts = []; foreach ($entries as $entry) { if ($entry['time']->lessThan($cutoff)) { continue; } $counts[$entry['ip']] = ($counts[$entry['ip']] ?? 0) + 1; } return $counts; } /** * `Failed password` und `Invalid user` — die Adresse steht in derselben * Zeile. `--since` hat den zeitlichen Ausschnitt schon auf den Gastagenten * (journalctl) verlagert, hier wird nur noch gezählt. * * @return array */ private function countSshFailures(string $out): array { $counts = []; foreach (preg_split('/\R/', trim($out)) ?: [] as $line) { if ($line === '' || (! str_contains($line, 'Failed password') && ! str_contains($line, 'Invalid user'))) { continue; } if (preg_match('/from ([0-9a-fA-F:.]+) port/', $line, $match) !== 1) { continue; } $counts[$match[1]] = ($counts[$match[1]] ?? 0) + 1; } return $counts; } }