}|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; } $node = (string) ($host->node ?? 'pve'); $vmid = (int) $instance->vmid; $stored = (int) $instance->security_log_offset; try { $pve = $this->pve->forHost($host); $size = $pve->guestExec($node, $vmid, NextcloudOcc::exec('stat -c %s data/nextcloud.log')); if ((int) ($size['exitcode'] ?? 1) !== 0) { return null; } $bytes = (int) trim((string) ($size['out-data'] ?? '')); // Der ERSTE Lauf holt nur den Startwert und liest nichts. // // `security_log_offset` beginnt bei 0, und 0 heißt wörtlich "von // vorn": ohne diesen Zweig tailt der allererste Lauf die KOMPLETTE // Protokolldatei jeder Instanz durch den Gastagenten, alle // Instanzen im selben Auftrag, in derselben Minute. Gesperrt würde // dabei ohnehin nichts — das Zeitfenster wirft jede Zeile weg, die // älter als zehn Minuten ist —, es wäre also eine große Übertragung // für ein garantiert leeres Ergebnis. // // Bewusst getrennt vom Rotationsfall unten: dort ist die Datei // NEUER als der Versatz und muss von vorn gelesen werden, weil das // Neue am Anfang steht. if ($stored === 0) { return ['offset' => $bytes, 'addresses' => []]; } // 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. $offset = $bytes < $stored ? 0 : $stored; $tail = $pve->guestExec($node, $vmid, NextcloudOcc::exec('tail -c +'.($offset + 1).' data/nextcloud.log')); } catch (Throwable) { // Wie fromHost(): ein abgeschalteter Gast oder ein Agent, der noch // nicht laeuft, wirft eine echte Ausnahme statt eines // Fehlercodes — `guestExec()` ruft `->throw()` auf jede // HTTP-Antwort. Genauso kein Vorfall wie ein sauberer // `exitcode != 0`, und kein Grund, den ganzen Lauf mitzureissen. return null; } 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; } // RIEGEL GEGEN BEFEHLSEINSCHLEUSUNG. `remoteAddr` ist eine // beliebige Zeichenkette aus einer Protokolldatei IM GAST, und der // Weg von hier führt über HostFirewall::block() in ein // `nft`-Kommando, das PhpseclibRemoteShell als ROOT auf dem // Proxmox-Host ausführt. Ein Semikolon darin wäre ein zweiter // Befehl. Der SSH-Zweig unten begrenzt den Zeichenvorrat schon über // sein `preg_match`; dieser Zweig hatte kein Gegenstück. if (filter_var($ip, FILTER_VALIDATE_IP) === false) { continue; } // VORSICHTSMASSNAHME, nicht Sicherheitsgrenze — und nur hier, nicht // im Host-Zweig. // // Traefik sitzt auf dem Host und leitet auf `guest_ip:80` weiter, // aber `TRUSTED_PROXIES` wird in die .env des Gastes nie // geschrieben. Was Nextcloud als `remoteAddr` protokolliert, ist // damit entweder die Brückenadresse des Hosts (dann zählen ALLE // Kunden unter einer Adresse und ein echter Angreifer nie) oder ein // `X-Forwarded-For`, das der Client SELBST wählt (dann macht sich // ein Angreifer mit `10.66.0.1` dauerhaft ausnahmefähig und sperrt // mit einer fremden Adresse gezielt Unbeteiligte aus). Welcher der // beiden Fälle zutrifft, ist ohne echte Instanz nicht zu // entscheiden. // // Beide Schadensfälle sind privat oder reserviert; ein echter // Angreifer sitzt auf einer öffentlichen Adresse. Bis die // Vertrauenskette zum Gast sauber gesetzt und BEWIESEN ist // (TRUSTED_PROXIES in die Gast-.env, dann `grep remoteAddr // data/nextcloud.log` an einer echten Instanz), sperrt der // Instanz-Zweig deshalb nur öffentliche Adressen. Diese Zeile // gehört dann weg — nicht früher. if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { 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; } // Derselbe Riegel wie im Instanz-Zweig. Der Zeichenvorrat oben // schließt ein Semikolon zwar schon aus, aber er lässt "1.2.3" und // "...." durch — und der Riegel darf nicht davon abhängen, dass // jemand beim nächsten Ändern des Musters daran denkt. // // OHNE die Einschränkung auf öffentliche Adressen: hier stammt die // Adresse aus dem SSH-Journal des Hosts und ist echt, und eine // Sperre aus dem Verwaltungsnetz fängt die Ausnahmeliste in // BlockAddress ohnehin ab. if (filter_var($match[1], FILTER_VALIDATE_IP) === false) { continue; } $counts[$match[1]] = ($counts[$match[1]] ?? 0) + 1; } return $counts; } }