213 lines
6.8 KiB
PHP
213 lines
6.8 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;
|
|
}
|
|
|
|
$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<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;
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
$counts[$match[1]] = ($counts[$match[1]] ?? 0) + 1;
|
|
}
|
|
|
|
return $counts;
|
|
}
|
|
}
|