CluPilotCloud/app/Services/Security/FailedLoginReader.php

289 lines
11 KiB
PHP

<?php
namespace App\Services\Security;
use App\Models\Host;
use App\Models\Instance;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Secrets\SecretVault;
use App\Services\Ssh\RemoteShell;
use App\Support\NextcloudOcc;
use Illuminate\Support\Carbon;
use Throwable;
/**
* Liest gescheiterte Anmeldungen aus zwei Quellen — beide nur LESEND, beide
* über den Weg, den das Produkt für dieses Subjekt schon benutzt: eine
* Kundeninstanz über den Proxmox-Gastagenten (wie jeder occ-Aufruf im
* Produkt), ein Host über SSH durch den Tunnel (wie HostFirewall).
*
* Zählt nicht selbst gegen die Schwelle — das ist Sache des Aufrufers
* (ScanForIntrusions). Diese Klasse liefert je Lauf nur, was seit dem letzten
* Mal an NEUEM dazugekommen ist, innerhalb des Zeitfensters, plus den Cursor,
* an dem der nächste Lauf weiterlesen muss.
*
* Ein Gast oder Host, der nicht antwortet, ist KEIN Vorfall: beide Methoden
* geben dann null zurück, statt zu werfen oder einen leeren Treffer
* vorzutäuschen — der Aufrufer lässt den Cursor in diesem Fall unangetastet.
*/
class FailedLoginReader
{
/** Nur Zeilen, die höchstens so alt sind, zählen gegen die Schwelle. */
private const WINDOW_MINUTES = 10;
public function __construct(
private ProxmoxClient $pve,
private RemoteShell $shell,
) {}
/**
* Nextclouds eigene Protokolldatei, ab dem gemerkten Byte-Versatz.
*
* @return array{offset: int, addresses: array<string, int>}|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<string, int>}|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<int, array{ip: string, time: Carbon}>
*/
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<int, array{ip: string, time: Carbon}> $entries
* @return array<string, int>
*/
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<string, int>
*/
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;
}
}