mailwolt/app/Livewire/Ui/Security/Fail2BanCard.php

762 lines
27 KiB
PHP

<?php
namespace App\Livewire\Ui\Security;
use Livewire\Attributes\On;
use Livewire\Component;
class Fail2BanCard extends Component
{
public bool $available = true;
public bool $permDenied = false;
public int $activeBans = 0;
public array $jails = [];
public function mount(): void
{
$this->load();
}
public function render()
{
return view('livewire.ui.security.fail2-ban-card');
}
#[On('f2b:refresh-banlist')]
public function refresh(): void
{
$this->load(true);
}
public function openDetails(string $jail): void
{
// KORREKTER DISPATCH für wire-elements/modal
$this->dispatch('openModal', 'ui.security.modal.fail2-ban-jail-modal', ['jail' => $jail]);
}
/* ------------------- intern ------------------- */
// protected function load(bool $force = false): void
// {
// $bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
// if ($bin === '') {
// $this->available = false;
// $this->permDenied = false;
// $this->activeBans = 0;
// $this->jails = [];
// return;
// }
//
// [$ok, $raw] = $this->f2b('ping');
// if (!$ok && stripos($raw, 'permission denied') !== false) {
// $this->available = true;
// $this->permDenied = true;
// $this->activeBans = 0;
// $this->jails = [];
// return;
// }
//
// [, $status] = $this->f2b('status');
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
//
// $rows = [];
// $sum = 0;
// foreach ($jails as $j) {
// [, $s] = $this->f2b('status '.escapeshellarg($j));
// $bantime = $this->getBantime($j);
// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
//
// $ipDetails = $this->buildIpDetails($j, $ips, $bantime);
//
// [, $s] = $this->f2b('status ' . escapeshellarg($j));
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
// $bantime = $this->getBantime($j);
// $rows[] = ['name' => $j, 'banned' => $banned, 'bantime' => $bantime];
// $sum += $banned;
// }
//
// $this->available = true;
// $this->permDenied = false;
// $this->activeBans = $sum;
// $this->jails = $rows;
// }
protected function load(bool $force = false): void
{
$bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
if ($bin === '') {
$this->available = false;
$this->permDenied = false;
$this->activeBans = 0;
$this->jails = [];
return;
}
// Rechte prüfen
[$ok, $raw] = $this->f2b('ping');
if (!$ok && stripos($raw, 'permission denied') !== false) {
$this->available = true;
$this->permDenied = true;
$this->activeBans = 0;
$this->jails = [];
return;
}
// Jail-Liste
[, $status] = $this->f2b('status');
$jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
$jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
$rows = [];
$sum = 0;
foreach ($jails as $j) {
$jEsc = escapeshellarg($j);
[, $s] = $this->f2b("status {$jEsc}");
$banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
$bantime = $this->getBantime($j);
$ipListLine = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
$ips = $ipListLine !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipListLine)))) : [];
// Details inkl. Restzeit je IP
$ipDetails = $this->buildIpDetails($j, $ips, $bantime);
$rows[] = [
'name' => $j,
'banned' => $banned,
'bantime' => $bantime, // Sek. (-1 = permanent)
'ips' => $ipDetails, // [['ip'=>..., 'remaining'=>..., 'until'=>...], ...]
];
$sum += $banned;
}
$this->available = true;
$this->permDenied = false;
$this->activeBans = $sum;
$this->jails = $rows;
}
// private function f2b(string $args): array
// {
// $sudo = '/usr/bin/sudo';
// $f2b = '/usr/bin/fail2ban-client';
// $out = (string)@shell_exec("timeout 2 $sudo -n $f2b $args 2>&1");
// $ok = str_contains($out, 'Status') || str_contains($out, 'Jail list') || str_contains($out, 'pong');
// return [$ok, $out];
// }
private function f2b(string $args): array
{
$sudo = '/usr/bin/sudo';
$f2b = '/usr/bin/fail2ban-client';
$cmd = "timeout 3 $sudo -n $f2b $args 2>&1";
$out = (string)@shell_exec($cmd);
$ok = stripos($out, 'Status') !== false
|| stripos($out, 'Jail list') !== false
|| stripos($out, 'pong') !== false;
return [$ok, $out];
}
/** konfig. Bantime des Jails in Sekunden (-1 = permanent) */
private function getBantime(string $jail): int
{
[, $out] = $this->f2b('get '.escapeshellarg($jail).' bantime');
$val = trim($out);
if (preg_match('/-?\d+/', $val, $m)) return (int)$m[0];
return 600; // konservativer Fallback
}
// private function getBantime(string $jail): int
// {
// [, $out] = $this->f2b('get ' . escapeshellarg($jail) . ' bantime');
// $val = trim($out);
// if (preg_match('/-?\d+/', $val, $m)) return (int)$m[0];
// return 600;
// }
/** Letzten Ban-Zeitpunkt (Unix-Timestamp) aus /var/log/fail2ban.log ermitteln. */
private function lastBanTimestamp(string $jail, string $ip): ?int
{
$file = '/var/log/fail2ban.log';
if (!is_readable($file)) return null;
// nur das Ende der Datei lesen (Performance, auch bei Rotation groß genug wählen)
$tailBytes = 400000; // 400 KB
$size = @filesize($file) ?: 0;
$seek = max(0, $size - $tailBytes);
$fh = @fopen($file, 'rb');
if (!$fh) return null;
if ($seek > 0) fseek($fh, $seek);
$data = stream_get_contents($fh) ?: '';
fclose($fh);
// Beispielzeile:
// 2025-10-30 22:34:20,797 fail2ban.actions [...] NOTICE [sshd] Ban 193.46.255.244
$j = preg_quote($jail, '/');
$p = preg_quote($ip, '/');
$pattern = '/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}),\d+.*\['.$j.'\]\s+Ban\s+'.$p.'\s*$/m';
if (preg_match_all($pattern, $data, $m) && !empty($m[1])) {
$date = end($m[1]); // YYYY-MM-DD
$time = end($m[2]); // HH:MM:SS
$dt = \DateTime::createFromFormat('Y-m-d H:i:s', "$date $time", new \DateTimeZone(date_default_timezone_get()));
return $dt ? $dt->getTimestamp() : null;
}
return null;
}
/** Baut Details inkl. Restzeit (Sekunden; -1 = permanent). */
private function buildIpDetails(string $jail, array $ips, int $bantime): array
{
$now = time();
$out = [];
foreach ($ips as $ip) {
$banAt = $this->lastBanTimestamp($jail, $ip);
$remaining = null;
$until = null;
if ($bantime === -1) {
$remaining = -1; // permanent
} elseif ($banAt !== null) {
$remaining = max(0, $bantime - ($now - $banAt));
$until = $remaining > 0 ? ($banAt + $bantime) : null;
}
$out[] = [
'ip' => $ip,
'remaining' => $remaining, // -1 = permanent, null = Ban-Zeitpunkt nicht gefunden, >=0 = Sekunden
'until' => $until, // Unix-Timestamp oder null
];
}
return $out;
}
private function firstMatch(string $pattern, string $haystack): ?string
{
return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
}
// private function firstMatch(string $pattern, string $haystack): ?string
// {
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
// }
}
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Component;
//
//class Fail2BanCard extends Component
//{
// public bool $available = true; // fail2ban-client vorhanden?
// public bool $permDenied = false; // Socket/Root-Rechte fehlen?
// public int $activeBans = 0; // Summe gebannter IPs
// /** @var array<int,array{name:string,banned:int,bantime:int}> */
// public array $jails = [];
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2-ban-card');
// }
//
// public function refresh(): void
// {
// $this->load(true);
// }
//
// // Optional: öffnet später dein Detail-Modal/Tab
// public function openDetails(string $jail): void
// {
// $this->dispatch('openModal', 'ui.security.modal.fail2-ban-jail-modal', ['jail' => $jail]);
// }
// /* ---------------- intern ---------------- */
//
// protected function load(bool $force = false): void
// {
// $bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
// if ($bin === '') {
// $this->available = false;
// $this->permDenied = false;
// $this->activeBans = 0;
// $this->jails = [];
// return;
// }
//
// // Rechtecheck
// [$ok, $raw] = $this->f2b('ping');
// if (!$ok && stripos($raw, 'permission denied') !== false) {
// $this->available = true;
// $this->permDenied = true;
// $this->activeBans = 0;
// $this->jails = [];
// return;
// }
//
// // Jails laden
// [, $status] = $this->f2b('status');
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
//
// $rows = [];
// $sum = 0;
//
// foreach ($jails as $j) {
// [, $s] = $this->f2b('status ' . escapeshellarg($j));
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
// $bantime = $this->getBantime($j); // Sek.; -1 = permanent
// $rows[] = ['name' => $j, 'banned' => $banned, 'bantime' => $bantime];
// $sum += $banned;
// }
//
// $this->available = true;
// $this->permDenied = false;
// $this->activeBans = $sum;
// $this->jails = $rows;
// }
//
// /** sudo + fail2ban-client ausführen; [ok, output] */
// private function f2b(string $args): array
// {
// $sudo = '/usr/bin/sudo';
// $f2b = '/usr/bin/fail2ban-client';
// $out = (string)@shell_exec("timeout 2 $sudo -n $f2b $args 2>&1");
// $ok = stripos($out, 'Status') !== false
// || stripos($out, 'Jail list') !== false
// || stripos($out, 'pong') !== false;
// return [$ok, $out];
// }
//
// private function getBantime(string $jail): int
// {
// [, $out] = $this->f2b('get ' . escapeshellarg($jail) . ' bantime');
// $val = trim($out);
// if (preg_match('/-?\d+/', $val, $m)) return (int)$m[0];
// return 600; // defensiver Default
// }
//
// private function firstMatch(string $pattern, string $haystack): ?string
// {
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
// }
//}
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Component;
//
//class Fail2BanCard extends Component
//{
// public bool $available = true; // fail2ban-client vorhanden?
// public bool $permDenied = false; // Socket/Root-Rechte fehlen?
// public int $activeBans = 0; // Summe gebannter IPs über alle Jails
// public array $jails = []; // [['name','banned','bantime','ips'=>[['ip','remaining','until'],...]],...]
// public array $topIps = []; // [['ip'=>'x.x.x.x','count'=>N], ...]
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2-ban-card');
// }
//
// /** Button „Neu prüfen“ */
// public function refresh(): void
// {
// $this->load(true);
// }
//
// /* --------------------- intern --------------------- */
//
// protected function load(bool $force = false): void
// {
// // existiert fail2ban-client?
// $bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
// if ($bin === '') {
// $this->available = false;
// $this->permDenied = false;
// $this->activeBans = 0;
// $this->jails = [];
// $this->topIps = [];
// return;
// }
//
// // ping → prüft zugleich Rechte (bei Permission-Fehler kommt Klartext)
// [$ok, $raw] = $this->f2b('ping'); // ok == "pong" erkannt
// if (!$ok && stripos($raw, 'permission denied') !== false) {
// $this->available = true;
// $this->permDenied = true;
// $this->activeBans = 0;
// $this->jails = [];
// $this->topIps = $this->collectTopIps();
// return;
// }
//
// // Jails auflisten
// [, $status] = $this->f2b('status');
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
//
// $total = 0;
// $rows = [];
//
// foreach ($jails as $j) {
// $bantimeSecs = $this->getBantime($j); // Sek., -1 = permanent
//
// [, $s] = $this->f2b('status ' . escapeshellarg($j));
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
//
// // Restzeiten je IP bestimmen (aus /var/log/fail2ban.log)
// $ipDetails = [];
// foreach (array_slice($ips, 0, 50) as $ip) {
// $banAt = $this->lastBanTimestamp($j, $ip); // Unix-Timestamp oder null
// $remaining = null;
// $until = null;
//
// if ($banAt !== null) {
// if ((int)$bantimeSecs === -1) {
// $remaining = -1; // permanent
// } else {
// $remaining = max(0, $bantimeSecs - (time() - $banAt));
// $until = $remaining > 0 ? ($banAt + $bantimeSecs) : null;
// }
// }
//
// $ipDetails[] = [
// 'ip' => $ip,
// 'remaining' => $remaining, // -1 = permanent, 0 = abgelaufen, >0 Sek.
// 'until' => $until, // Unix-Timestamp oder null
// ];
// }
//
// $rows[] = [
// 'name' => $j,
// 'banned' => $banned,
// 'ips' => $ipDetails,
// 'bantime' => (int)$bantimeSecs,
// ];
// $total += $banned;
// }
//
// $this->available = true;
// $this->permDenied = false;
// $this->activeBans = $total;
// $this->jails = $rows;
// $this->topIps = $this->collectTopIps();
// }
//
// /** führt fail2ban-client via sudo aus; gibt [ok, output] zurück */
// private function f2b(string $args): array
// {
// $sudo = '/usr/bin/sudo';
// $f2b = '/usr/bin/fail2ban-client';
// $cmd = "timeout 2 $sudo -n $f2b $args 2>&1";
// $out = (string)@shell_exec($cmd);
//
// $ok = stripos($out, 'Status') !== false
// || stripos($out, 'Jail list') !== false
// || stripos($out, 'pong') !== false;
//
// return [$ok, $out];
// }
//
// private function getBantime(string $jail): int
// {
// [, $out] = $this->f2b('get ' . escapeshellarg($jail) . ' bantime');
// $val = trim($out);
// if (preg_match('/-?\d+/', $val, $m)) {
// return (int)$m[0];
// }
// return 600; // defensiver Default
// }
//
// /** letzte Ban-Zeile aus /var/log/fail2ban.log → Unix-Timestamp */
// private function lastBanTimestamp(string $jail, string $ip): ?int
// {
// $cmd = "grep -F \"[{$jail}] Ban {$ip}\" /var/log/fail2ban.log 2>/dev/null | tail -n 1";
// $line = trim((string)@shell_exec($cmd));
// if ($line === '') return null;
//
// // "YYYY-MM-DD HH:MM:SS,mmm ..."
// if (preg_match('/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})/', $line, $m)) {
// $ts = strtotime($m[1] . ' ' . $m[2]);
// return $ts ?: null;
// }
// return null;
// }
//
// private function firstMatch(string $pattern, string $haystack): ?string
// {
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
// }
//
// /** Top-IPs grob zählen (aus der aktuellen Jail-Liste; Fallback: Log) */
// private function collectTopIps(): array
// {
// $map = [];
// foreach ($this->jails as $jail) {
// foreach ($jail['ips'] as $row) {
// $ip = $row['ip'] ?? null;
// if (!$ip) continue;
// $map[$ip] = ($map[$ip] ?? 0) + 1;
// }
// }
//
// if (!empty($map)) {
// arsort($map);
// $out = [];
// foreach (array_slice($map, 0, 5, true) as $ip => $count) {
// $out[] = ['ip' => $ip, 'count' => $count];
// }
// return $out;
// }
//
// // Fallback: aus fail2ban.log
// $cmd = 'grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" /var/log/fail2ban.log 2>/dev/null'
// . ' | sort | uniq -c | sort -nr | head -5';
// $log = (string)@shell_exec($cmd);
// $rows = [];
// if ($log !== '') {
// foreach (preg_split('/\R+/', trim($log)) as $l) {
// if (preg_match('/^\s*(\d+)\s+(\d+\.\d+\.\d+\.\d+)/', $l, $m)) {
// $rows[] = ['ip' => $m[2], 'count' => (int)$m[1]];
// }
// }
// }
// return $rows;
// }
//}
//
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Component;
//
//class Fail2BanCard extends Component
//{
// public bool $available = true; // fail2ban-client vorhanden?
// public bool $permDenied = false; // Socket/Root-Rechte fehlen?
// public int $activeBans = 0; // Summe gebannter IPs über alle Jails
// public array $jails = []; // [['name'=>'sshd','banned'=>2,'ips'=>['1.2.3.4',...]], ...]
// public array $topIps = []; // [['ip'=>'x.x.x.x','count'=>N], ...]
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2-ban-card');
// }
//
// // Wird vom Button "Neu prüfen" genutzt
// public function refresh(): void
// {
// $this->load(true);
// }
//
// /* --------------------- intern --------------------- */
//
// protected function load(bool $force = false): void
// {
// // existiert fail2ban-client?
// $bin = trim((string) @shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
// if ($bin === '') {
// $this->available = false;
// $this->permDenied = false;
// $this->activeBans = 0;
// $this->jails = [];
// $this->topIps = [];
// return;
// }
//
// // ping → prüft zugleich Rechte (bei Permission-Fehler kommt Klartext)
// [$ok, $raw] = $this->f2b('ping'); // ok == "pong" erkannt
// if (!$ok && stripos($raw, 'permission denied') !== false) {
// $this->available = true;
// $this->permDenied = true;
// $this->activeBans = 0;
// $this->jails = [];
// $this->topIps = $this->collectTopIps();
// return;
// }
//
// // Jails auflisten
// [, $status] = $this->f2b('status');
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
//
// $total = 0; $rows = [];
//// ... in load() NACH dem Einlesen der Jail-Liste:
// $rows = [];
// foreach ($jails as $j) {
// $bantimeSecs = $this->getBantime($j); // konfigurierter Wert (Sekunden, -1 = permanent)
//
// [, $s] = $this->f2b('status '.escapeshellarg($j));
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
//
// // Restzeiten je IP bestimmen (aus /var/log/fail2ban.log)
// $ipDetails = [];
// foreach (array_slice($ips, 0, 50) as $ip) {
// $banAt = $this->lastBanTimestamp($j, $ip); // Unix-Timestamp oder null
// $remaining = null;
// $until = null;
//
// if ($banAt !== null) {
// if ((int)$bantimeSecs === -1) {
// $remaining = -1; // permanent
// } else {
// $remaining = max(0, $bantimeSecs - (time() - $banAt));
// $until = $remaining > 0 ? ($banAt + $bantimeSecs) : null;
// }
// }
//
// $ipDetails[] = [
// 'ip' => $ip,
// 'remaining' => $remaining, // -1 = permanent, 0 = abgelaufen, >0 Sek.
// 'until' => $until, // Unix-Timestamp oder null
// ];
// }
//
// $rows[] = [
// 'name' => $j,
// 'banned' => $banned,
// 'ips' => $ipDetails, // jetzt mit Details
// 'bantime' => (int)$bantimeSecs,
// ];
// $total += $banned;
// }
//
// // foreach ($jails as $j) {
//// [, $s] = $this->f2b('status '.escapeshellarg($j));
//// $banned = (int) ($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
//// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
//// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
//// $rows[] = ['name'=>$j,'banned'=>$banned,'ips'=>array_slice($ips, 0, 8)];
//// $total += $banned;
//// }
//
//
//
// $this->available = true;
// $this->permDenied = false;
// $this->activeBans = $total;
// $this->jails = $rows;
// $this->topIps = $this->collectTopIps();
// }
//
// /** führt fail2ban-client via sudo aus; gibt [ok, output] zurück */
// private function f2b(string $args): array
// {
// $sudo = '/usr/bin/sudo';
// $f2b = '/usr/bin/fail2ban-client';
// $cmd = "timeout 2 $sudo -n $f2b $args 2>&1";
// $out = (string) @shell_exec($cmd);
//
// $ok = stripos($out, 'Status') !== false
// || stripos($out, 'Jail list') !== false
// || stripos($out, 'pong') !== false;
//
// return [$ok, $out];
// }
//
// private function getBantime(string $jail): int
// {
// [, $out] = $this->f2b('get '.escapeshellarg($jail).' bantime');
// // fail2ban liefert Seconds als Zahl (oder mit Newline)
// $val = trim($out);
// // Fallback: manche Versionen geben nur Zahl ohne Kontext zurück,
// // sonst aus jail.local ermitteln wäre overkill -> einfache Zahl extrahieren:
// if (preg_match('/-?\d+/', $val, $m)) {
// return (int)$m[0];
// }
// // wenn nicht ermittelbar: 600 Sekunden als conservative default
// return 600;
// }
//
// /** Sucht die letzte "Ban <IP>"-Zeile für Jail in /var/log/fail2ban.log und gibt Unix-Timestamp zurück. */
// private function lastBanTimestamp(string $jail, string $ip): ?int
// {
// // Beispiel-Logzeilen:
// // 2025-10-29 18:07:11,436 fail2ban.actions [12345]: NOTICE [sshd] Ban 1.2.3.4
// // Wir holen die letzte passende Zeile (tail mit grep), dann parsen Datum.
// $pattern = escapeshellarg(sprintf('\\[%s\\] Ban %s', $jail, $ip));
// $cmd = "grep -F \"[{$jail}] Ban {$ip}\" /var/log/fail2ban.log 2>/dev/null | tail -n 1";
// $line = (string)@shell_exec($cmd);
// $line = trim($line);
// if ($line === '') {
// return null;
// }
// // Datumsformat am Anfang: "YYYY-MM-DD HH:MM:SS,mmm"
// if (preg_match('/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})/', $line, $m)) {
// $ts = strtotime($m[1].' '.$m[2]);
// return $ts ?: null;
// }
// return null;
// }
//
// private function firstMatch(string $pattern, string $haystack): ?string
// {
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
// }
//
// /** Zählt die häufigsten IPs aus den letzten Fail2Ban-Logs (ban/unban Events) */
// private function collectTopIps(): array
// {
// // 1. Versuch: IPs direkt aus den Jails
// $rows = [];
// foreach ($this->jails as $jail) {
// foreach ($jail['ips'] as $ip) {
// $rows[$ip] = ($rows[$ip] ?? 0) + 1;
// }
// }
//
// if (!empty($rows)) {
// arsort($rows);
// return collect($rows)
// ->map(fn($count, $ip) => ['ip' => $ip, 'count' => $count])
// ->values()
// ->take(5)
// ->toArray();
// }
//
// // 2. Fallback: Falls keine Jails/IPs → Logdatei
// $cmd = 'grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" /var/log/fail2ban.log 2>/dev/null'
// . ' | sort | uniq -c | sort -nr | head -5';
// $log = (string) @shell_exec($cmd);
//
// $rows = [];
// if ($log !== '') {
// foreach (preg_split('/\R+/', trim($log)) as $l) {
// if (preg_match('/^\s*(\d+)\s+(\d+\.\d+\.\d+\.\d+)/', $l, $m)) {
// $rows[] = ['ip'=>$m[2],'count'=>(int)$m[1]];
// }
// }
// }
// return $rows;
// }
//}