mailwolt/app/Livewire/Ui/Security/Modal/Fail2BanJailModal.php

226 lines
7.2 KiB
PHP

<?php
namespace App\Livewire\Ui\Security\Modal;
use LivewireUI\Modal\ModalComponent;
class Fail2BanJailModal extends ModalComponent
{
public string $jail = '';
/** @var array<int,array{
* ip:string,
* bantime:int, // Sek.; -1 = permanent
* banned_at:?int, // Unix-Timestamp
* remaining:?int, // -1=permanent, 0..Sekunden, null=unbekannt
* until:?int, // Unix-Timestamp oder null
* time_text:string, // "HHh MMm SSs", "permanent", "—"
* meta_text:string, // "seit … • bis …" oder "seit …" oder "—"
* box_class:string // Tailwind-Klassen für BG/Border
* }]
*/
public array $rows = [];
public static function modalMaxWidth(): string
{
return '4xl';
}
public function mount(string $jail): void
{
$this->jail = $jail;
$this->load();
}
public function refresh(): void
{
$this->load(true);
}
public function render()
{
return view('livewire.ui.security.modal.fail2-ban-jail-modal');
}
/* ---------------- intern ---------------- */
protected function load(bool $force = false): void
{
$jail = $this->jail;
// Aktuell gebannte IPs
[, $s] = $this->f2b('status '.escapeshellarg($jail));
$ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
$ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
// bantime für Jail
$bantime = $this->getBantime($jail);
$rows = [];
foreach ($ips as $ip) {
$banAt = $this->lastBanTimestamp($jail, $ip); // Unix-Timestamp oder null
$remaining = null;
$until = null;
if ($banAt !== null) {
if ($bantime === -1) {
$remaining = -1;
} else {
$remaining = max(0, $bantime - (time() - $banAt));
$until = $remaining > 0 ? $banAt + $bantime : null;
}
}
// Darstellung
[$timeText, $metaText, $boxClass] = $this->present($remaining, $banAt, $until);
$rows[] = [
'ip' => $ip,
'bantime' => $bantime,
'banned_at' => $banAt,
'remaining' => $remaining,
'until' => $until,
'time_text' => $timeText,
'meta_text' => $metaText,
'box_class' => $boxClass,
];
}
$this->rows = $rows;
}
/** Darstellung ableiten (Farbcode + Texte) */
private function present(?int $remaining, ?int $banAt, ?int $until): array
{
// Farben an dein Schema angelehnt
$green = 'border-emerald-400/30 bg-emerald-500/10';
$amber = 'border-amber-400/30 bg-amber-500/10';
$rose = 'border-rose-400/30 bg-rose-500/10';
$muted = 'border-white/10 bg-white/5';
if ($remaining === -1) {
$time = 'permanent';
$meta = $banAt ? ('seit '.$this->fmtTs($banAt)) : '—';
return [$time, $meta, $rose];
}
if (is_int($remaining)) {
if ($remaining > 0) {
$time = $this->fmtSecs($remaining);
$parts = [];
if ($banAt) $parts[] = 'seit '.$this->fmtTs($banAt);
if ($until) $parts[] = 'bis '.$this->fmtTs($until);
$meta = $parts ? implode(' • ', $parts) : '—';
return [$time, $meta, $amber];
}
// 0 Sekunden -> theoretisch abgelaufen
$time = 'abgelaufen';
$meta = $banAt ? ('seit '.$this->fmtTs($banAt)) : '—';
return [$time, $meta, $muted];
}
// keine Info
return ['—', '—', $muted];
}
/** sudo + fail2ban-client */
private function f2b(string $args): array
{
$sudo = '/usr/bin/sudo';
$f2b = '/usr/bin/fail2ban-client';
$out = (string) @shell_exec("timeout 3 $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;
}
/** letzte "Ban <IP>"-Zeile parsen -> Unix-Timestamp */
/** letzte "Ban <IP>"-Zeile -> Unix-Timestamp aus /var/log/fail2ban.log */
private function lastBanTimestamp(string $jail, string $ip): ?int
{
$file = '/var/log/fail2ban.log';
if (!is_readable($file)) return null;
// nur das Dateiende lesen (performant, auch bei großen Logs)
$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);
// Beispiel: 2025-10-30 22:34:20,797 ... [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]);
$time = end($m[2]);
$dt = \DateTime::createFromFormat('Y-m-d H:i:s', "$date $time",
new \DateTimeZone(date_default_timezone_get()));
return $dt ? $dt->getTimestamp() : null;
}
return null;
}
private function firstMatch(string $pattern, string $haystack): ?string
{
return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
}
private function buildIpDetails(string $jail, array $ips, int $bantime): array
{
$out = [];
$now = time();
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=unbekannt, 0..N Sekunden
'until' => $until,
];
}
return $out;
}
private function fmtSecs(int $s): string
{
// kompakt: 1h 23m 45s / 05m 10s / 12s
$h = intdiv($s, 3600);
$m = intdiv($s % 3600, 60);
$r = $s % 60;
if ($h > 0) return sprintf('%dh %02dm %02ds', $h, $m, $r);
if ($m > 0) return sprintf('%02dm %02ds', $m, $r);
return sprintf('%ds', $r);
}
private function fmtTs(int $ts): string
{
// 29.10. 18:07
return date('d.m. H:i', $ts);
}
}