parent
9acea7b89b
commit
e3dc81ef73
|
|
@ -14,20 +14,37 @@ class Fail2banBanlist extends Component
|
|||
*/
|
||||
public ?string $jail = null;
|
||||
|
||||
/** @var array<int,string> */
|
||||
public array $banned = [];
|
||||
/**
|
||||
* Struktur für Blade (reine Ausgabe, keine Logik im Blade):
|
||||
* [
|
||||
* [
|
||||
* 'ip' => '1.2.3.4',
|
||||
* 'jail' => 'recidive',
|
||||
* 'permanent' => false,
|
||||
* 'label' => 'Temporär', // oder 'Permanent'
|
||||
* 'box' => 'border-amber-400/20 bg-white/3', // Kartenstil
|
||||
* 'badge' => 'border-amber-400/30 bg-amber-500/10 text-amber-200',
|
||||
* 'btn' => 'border-rose-400/30 bg-rose-500/10 text-rose-200 hover:border-rose-400/50',
|
||||
* ],
|
||||
* ...
|
||||
* ]
|
||||
*
|
||||
* @var array<int,array{
|
||||
* ip:string,jail:string,permanent:bool,label:string,box:string,badge:string,btn:string
|
||||
* }>
|
||||
*/
|
||||
public array $rows = [];
|
||||
|
||||
#[On('f2b:refresh')]
|
||||
public function refreshList(): void
|
||||
{
|
||||
$this->loadBannedIps();
|
||||
$this->loadBanned();
|
||||
}
|
||||
|
||||
public function mount(?string $jail = null): void
|
||||
{
|
||||
// erlaubt optionalen Param via <livewire ... :jail="'recidive'" />
|
||||
$this->jail = $jail;
|
||||
$this->loadBannedIps();
|
||||
$this->loadBanned();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
@ -37,61 +54,119 @@ class Fail2banBanlist extends Component
|
|||
|
||||
/* ================= core ================= */
|
||||
|
||||
private function loadBannedIps(): void
|
||||
private function loadBanned(): void
|
||||
{
|
||||
$jails = $this->jailList();
|
||||
|
||||
// ggf. nur ein bestimmtes Jail anzeigen
|
||||
// ggf. nur ein bestimmtes Jail
|
||||
if (is_string($this->jail) && $this->jail !== '' && $this->jail !== '*') {
|
||||
$jails = in_array($this->jail, $jails, true) ? [$this->jail] : [];
|
||||
}
|
||||
|
||||
$ips = [];
|
||||
$rows = [];
|
||||
foreach ($jails as $j) {
|
||||
$out = $this->f2b("status ".escapeshellarg($j));
|
||||
if (preg_match('/IP list:\s*(.+)$/mi', $out, $m)) {
|
||||
$parts = preg_split('/\s+/', trim($m[1]));
|
||||
foreach ($parts as $ip) {
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) $ips[] = $ip;
|
||||
$out = $this->f2b("status " . escapeshellarg($j));
|
||||
if (!preg_match('/IP list:\s*(.+)$/mi', $out, $m)) {
|
||||
continue;
|
||||
}
|
||||
$ips = preg_split('/\s+/', trim($m[1])) ?: [];
|
||||
foreach ($ips as $ip) {
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$permanent = $this->isPermanent($j, $ip);
|
||||
|
||||
// Präsentationsklassen zentral definieren
|
||||
if ($permanent) {
|
||||
$box = 'border-rose-400/30 bg-rose-500/5';
|
||||
$badge = 'border-rose-400/30 bg-rose-500/10 text-rose-200';
|
||||
$label = 'Permanent';
|
||||
} else {
|
||||
$box = 'border-amber-400/20 bg-white/3';
|
||||
$badge = 'border-amber-400/30 bg-amber-500/10 text-amber-200';
|
||||
$label = 'Temporär';
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'ip' => $ip,
|
||||
'jail' => $j,
|
||||
'permanent' => $permanent,
|
||||
'label' => $label,
|
||||
'box' => $box,
|
||||
'badge' => $badge,
|
||||
'btn' => 'border-rose-400/30 bg-rose-500/10 text-rose-200 hover:border-rose-400/50',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->banned = array_values(array_unique($ips));
|
||||
// Sortierung: permanent oben, dann nach Jail, dann IP
|
||||
usort($rows, function ($a, $b) {
|
||||
if ($a['permanent'] !== $b['permanent']) return $a['permanent'] ? -1 : 1;
|
||||
if ($a['jail'] !== $b['jail']) return strcmp($a['jail'], $b['jail']);
|
||||
return strcmp($a['ip'], $b['ip']);
|
||||
});
|
||||
|
||||
$this->rows = $rows;
|
||||
}
|
||||
|
||||
/** Entbannt IP in allen passenden Jails */
|
||||
public function unban(string $ip): void
|
||||
/** Entbannt eine IP **im angegebenen Jail** (Button gibt Jail mit) */
|
||||
public function unban(string $ip, string $jail): void
|
||||
{
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP)) return;
|
||||
|
||||
$jails = $this->jailList();
|
||||
if (is_string($this->jail) && $this->jail !== '' && $this->jail !== '*') {
|
||||
$jails = in_array($this->jail, $jails, true) ? [$this->jail] : [];
|
||||
}
|
||||
$cmd = sprintf(
|
||||
'sudo -n /usr/bin/fail2ban-client set %s unbanip %s 2>&1',
|
||||
escapeshellarg($jail),
|
||||
escapeshellarg($ip)
|
||||
);
|
||||
@shell_exec($cmd);
|
||||
|
||||
$cnt = 0;
|
||||
foreach ($jails as $j) {
|
||||
$this->f2b("set ".escapeshellarg($j)." unbanip ".escapeshellarg($ip));
|
||||
$cnt++;
|
||||
}
|
||||
|
||||
$this->loadBannedIps();
|
||||
$this->loadBanned();
|
||||
|
||||
$this->dispatch('toast',
|
||||
type: 'done',
|
||||
badge: 'Fail2Ban',
|
||||
title: 'IP entbannt',
|
||||
text: ($this->jail && $this->jail !== '*')
|
||||
? "IP {$ip} in Jail „{$this->jail}“ entbannt."
|
||||
: "IP {$ip} in {$cnt} Jail(s) entbannt.",
|
||||
text: "IP {$ip} in Jail „{$jail}“ entbannt.",
|
||||
duration: 5000,
|
||||
);
|
||||
}
|
||||
|
||||
/* ================= helpers ================= */
|
||||
|
||||
/** Liefert Liste aller Jails über fail2ban-client */
|
||||
/** Prüft via SQLite, ob der **letzte** Ban für (jail, ip) permanent ist (bantime < 0). */
|
||||
private function isPermanent(string $jail, string $ip): bool
|
||||
{
|
||||
$db = $this->getDbFile();
|
||||
if ($db === '' || !is_readable($db)) {
|
||||
// Fallback: Blacklist-Jail ist per Design permanent
|
||||
return $jail === 'mailwolt-blacklist';
|
||||
}
|
||||
|
||||
$q = <<<SQL
|
||||
WITH last AS (
|
||||
SELECT MAX(timeofban) AS t
|
||||
FROM bans
|
||||
WHERE jail = '$jail' AND ip = '$ip'
|
||||
)
|
||||
SELECT bantime
|
||||
FROM bans, last
|
||||
WHERE jail = '$jail' AND ip = '$ip' AND timeofban = last.t
|
||||
LIMIT 1;
|
||||
SQL;
|
||||
|
||||
$cmd = sprintf(
|
||||
'sudo -n /usr/bin/sqlite3 -readonly %s %s 2>&1',
|
||||
escapeshellarg($db),
|
||||
escapeshellarg($q)
|
||||
);
|
||||
$out = trim((string)@shell_exec($cmd));
|
||||
if ($out === '') return ($jail === 'mailwolt-blacklist'); // Fallback
|
||||
return ((int)$out) < 0;
|
||||
}
|
||||
|
||||
/** Liste aller Jails */
|
||||
private function jailList(): array
|
||||
{
|
||||
$out = $this->f2b('status');
|
||||
|
|
@ -102,9 +177,19 @@ class Fail2banBanlist extends Component
|
|||
return [];
|
||||
}
|
||||
|
||||
/** führt fail2ban-client via sudo aus und gibt stdout zurück */
|
||||
/** fail2ban-client über sudo aufrufen */
|
||||
private function f2b(string $args): string
|
||||
{
|
||||
return (string) @shell_exec('sudo -n /usr/bin/fail2ban-client '.$args.' 2>&1');
|
||||
}
|
||||
|
||||
/** Pfad zur Fail2Ban-SQLite-DB holen */
|
||||
private function getDbFile(): string
|
||||
{
|
||||
$out = $this->f2b('get dbfile');
|
||||
$lines = array_values(array_filter(array_map('trim', preg_split('/\r?\n/', $out))));
|
||||
$path = end($lines) ?: '';
|
||||
$path = preg_replace('/^`?-?\s*/', '', $path);
|
||||
return $path ?: '/var/lib/fail2ban/fail2ban.sqlite3';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
namespace App\Livewire\Ui\Security;
|
||||
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
use App\Models\Fail2banSetting;
|
||||
use App\Models\Fail2banIpList;
|
||||
|
|
|
|||
|
|
@ -1,39 +1,34 @@
|
|||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Aktuell gebannte IPs</h3>
|
||||
<h3 class="text-lg font-semibold text-white/90">Aktuell gebannte IPs</h3>
|
||||
<button wire:click="refreshList"
|
||||
class="px-3 py-1.5 text-sm bg-gray-700 text-white rounded hover:bg-gray-600">
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20">
|
||||
<i class="ph ph-arrows-counter-clockwise text-[14px]"></i>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (empty($banned))
|
||||
<div class="text-gray-500 text-sm">
|
||||
Keine aktiven Banns vorhanden.
|
||||
</div>
|
||||
@if (empty($rows))
|
||||
<div class="text-white/50 text-sm">Keine aktiven Banns vorhanden.</div>
|
||||
@else
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table class="min-w-full text-sm text-left">
|
||||
<thead class="bg-gray-100 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-4 py-2 font-medium text-gray-700 dark:text-gray-300">IP-Adresse</th>
|
||||
<th class="px-4 py-2 font-medium text-gray-700 dark:text-gray-300 w-32 text-right">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach ($banned as $ip)
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/30">
|
||||
<td class="px-4 py-2 text-gray-800 dark:text-gray-100">{{ $ip }}</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
<button wire:click="unban('{{ $ip }}')"
|
||||
class="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-500">
|
||||
Entbannen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="space-y-3">
|
||||
@foreach ($rows as $r)
|
||||
<div class="flex items-center justify-between rounded-2xl border px-4 py-3 {{ $r['box'] }}">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-white/90 font-medium tracking-wide">{{ $r['ip'] }}</div>
|
||||
<span class="text-xs rounded-full px-2 py-0.5 border {{ $r['badge'] }}">
|
||||
{{ $r['style'] === 'permanent' ? 'Permanent' : 'Temporär' }}
|
||||
</span>
|
||||
<span class="text-xs text-white/50">Jail: {{ $r['jail'] }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
wire:click="unban('{{ $r['ip'] }}','{{ $r['jail'] }}')"
|
||||
class="text-[13px] px-3 py-2 rounded-xl border border-rose-400/30 bg-rose-500/10 text-rose-200 hover:border-rose-400/50">
|
||||
Entbannen
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<span class="text-[11px] uppercase tracking-wide text-white/70">Fail2Ban Konfiguration</span>
|
||||
</div>
|
||||
<button wire:click="save"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-white/80 hover:text-white hover:border-white/20">
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20">
|
||||
<i class="ph ph-floppy-disk text-[14px]"></i> Speichern & Reload
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue