feat(servers): move fail2ban banned-IP list into a scrollable modal

The fail2ban-Status panel listed every jail + banned IP inline (very long). Now a
compact summary + 'Gesperrte IPs ansehen' opens a scrollable modal (Fail2banBans)
that reuses Fail2banService::status/unban and dispatches fail2banChanged. serverId
is #[Locked].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 23:10:36 +02:00
parent dc885e293b
commit 8b1bbf401a
5 changed files with 187 additions and 24 deletions

View File

@ -0,0 +1,112 @@
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\Fail2banService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Locked;
use LivewireUI\Modal\ModalComponent;
use Throwable;
/**
* Scrollable list of fail2ban jails + their currently-banned IPs, lifted out of the
* servers/show panel so a long ban list never stretches the page. mount() reads the
* live jail status via Fail2banService (the SAME service the page uses); unban() calls
* Fail2banService::unban (the SAME path as Show::unbanIp), audits the success, refreshes
* this modal's list, and dispatches `fail2banChanged` so the page panel re-reads too.
*/
class Fail2banBans extends ModalComponent
{
#[Locked]
public int $serverId;
/** Jail status rows (name/currentlyBanned/currentlyFailed/bannedIps) for the list. */
public array $jails = [];
/** True once the live status was read (distinguishes "no bans" from "not loaded"). */
public bool $loaded = false;
public ?string $error = null;
public function mount(int $serverId, Fail2banService $fail2ban): void
{
$this->serverId = $serverId;
$this->loadJails($fail2ban);
}
public static function modalMaxWidth(): string
{
return 'lg';
}
/** Read the live jail/banned-IP status (same Fail2banService::status the page calls). */
private function loadJails(Fail2banService $fail2ban): void
{
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
return;
}
try {
$status = $fail2ban->status($server);
$this->jails = $status['jails'] ?? [];
$this->error = ! empty($status['readError']) ? __('servers.fail2ban_read_error') : null;
$this->loaded = true;
} catch (Throwable $e) {
$this->error = Str::limit($e->getMessage(), 120);
}
}
/**
* Unban one IP mirrors Show::unbanIp: call Fail2banService::unban, audit the
* success, refresh this list, and dispatch `fail2banChanged` so the page panel
* re-reads its own fail2ban state.
*/
public function unbanIp(string $jail, string $ip, Fail2banService $fail2ban): void
{
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
return;
}
try {
$res = $fail2ban->unban($server, $jail, $ip);
} catch (Throwable $e) {
$this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($e->getMessage(), 90)]));
return;
}
if (! $res['ok']) {
$this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($res['output'] ?: __('servers.error_unknown'), 90)]));
return;
}
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'fail2ban.unban',
'target' => $ip.' · '.$jail.' · '.$server->name,
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('servers.notify_ip_unbanned', ['ip' => $ip]));
// Re-read this modal's list, then tell the page panel to re-pull its own state.
$this->loadJails($fail2ban);
$this->dispatch('fail2banChanged');
}
public function render()
{
return view('livewire.modals.fail2ban-bans');
}
}

View File

@ -121,6 +121,10 @@ return [
'fail2ban_banned' => 'gebannt: :count',
'fail2ban_failed' => 'Fehlversuche: :count',
'fail2ban_no_banned' => 'Keine gebannten IPs.',
'fail2ban_jails_count' => ':count Jails',
'fail2ban_view_bans' => 'Gesperrte IPs ansehen',
'fail2ban_bans_title' => 'Gesperrte IPs',
'fail2ban_bans_subtitle' => 'Aktive fail2ban-Jails und ihre aktuell gesperrten IP-Adressen. Zum Entsperren einer IP auf „Entsperren" tippen.',
'whitelist_label' => 'Whitelist (ignoreip)',
'whitelist_placeholder' => 'IP oder CIDR',

View File

@ -121,6 +121,10 @@ return [
'fail2ban_banned' => 'banned: :count',
'fail2ban_failed' => 'failed attempts: :count',
'fail2ban_no_banned' => 'No banned IPs.',
'fail2ban_jails_count' => ':count jails',
'fail2ban_view_bans' => 'View banned IPs',
'fail2ban_bans_title' => 'Banned IPs',
'fail2ban_bans_subtitle' => 'Active fail2ban jails and their currently banned IP addresses. Tap “Unlock” to unban an IP.',
'whitelist_label' => 'Whitelist (ignoreip)',
'whitelist_placeholder' => 'IP or CIDR',

View File

@ -0,0 +1,55 @@
<div class="p-5 sm:p-6">
<div class="flex items-start gap-3.5">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-offline/30 bg-offline/10 text-offline">
<x-icon name="shield" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-base font-semibold text-ink">{{ __('servers.fail2ban_bans_title') }}</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('servers.fail2ban_bans_subtitle') }}</p>
</div>
</div>
@if ($error)
<div class="mt-4 flex items-center gap-2.5 rounded-md border border-offline/25 bg-offline/10 px-3.5 py-3">
<x-icon name="alert" class="h-4 w-4 shrink-0 text-offline" />
<p class="text-sm text-ink-2">{{ $error }}</p>
</div>
@endif
<div class="mt-4 max-h-96 divide-y divide-line overflow-y-auto rounded-md border border-line">
@forelse ($jails as $jail)
<div class="px-3.5 py-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="font-mono text-sm text-ink">{{ $jail['name'] }}</p>
<p class="font-mono text-[11px] text-ink-3">
<span @class(['text-offline' => ($jail['currentlyBanned'] ?? 0) > 0])>{{ __('servers.fail2ban_banned', ['count' => $jail['currentlyBanned'] ?? 0]) }}</span>
· {{ __('servers.fail2ban_failed', ['count' => $jail['currentlyFailed'] ?? 0]) }}
</p>
</div>
@if (count($jail['bannedIps'] ?? []))
<div class="mt-2 space-y-1">
@foreach ($jail['bannedIps'] as $ip)
<div class="flex items-center justify-between gap-2">
<span class="min-w-0 break-all font-mono text-[11px] text-ink-2">{{ $ip }}</span>
<x-btn variant="secondary" size="sm" class="shrink-0"
wire:click="unbanIp({{ \Illuminate\Support\Js::from($jail['name']) }}, {{ \Illuminate\Support\Js::from($ip) }})"
wire:target="unbanIp({{ \Illuminate\Support\Js::from($jail['name']) }}, {{ \Illuminate\Support\Js::from($ip) }})"
wire:loading.attr="disabled">{{ __('common.unlock') }}</x-btn>
</div>
@endforeach
</div>
@else
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('servers.fail2ban_no_banned') }}</p>
@endif
</div>
@empty
<div class="px-3.5 py-6 text-center">
<p class="font-mono text-[11px] text-ink-3">{{ $loaded ? __('servers.fail2ban_no_banned') : __('servers.loading') }}</p>
</div>
@endforelse
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
</div>
</div>

View File

@ -404,30 +404,18 @@
</x-btn>
</div>
@else
<div class="divide-y divide-line">
@foreach ($f2bJails as $jail)
<div class="px-4 py-3 sm:px-5">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="font-mono text-sm text-ink">{{ $jail['name'] }}</p>
<p class="font-mono text-[11px] text-ink-3">
<span @class(['text-offline' => ($jail['currentlyBanned'] ?? 0) > 0])>{{ __('servers.fail2ban_banned', ['count' => $jail['currentlyBanned'] ?? 0]) }}</span>
· {{ __('servers.fail2ban_failed', ['count' => $jail['currentlyFailed'] ?? 0]) }}
</p>
</div>
@if (count($jail['bannedIps'] ?? []))
<div class="mt-2 space-y-1">
@foreach ($jail['bannedIps'] as $ip)
<div class="flex items-center justify-between gap-2">
<span class="min-w-0 break-all font-mono text-[11px] text-ink-2">{{ $ip }}</span>
<x-btn variant="secondary" size="sm" class="shrink-0" wire:click="unbanIp({{ \Illuminate\Support\Js::from($jail['name']) }}, {{ \Illuminate\Support\Js::from($ip) }})">{{ __('common.unlock') }}</x-btn>
</div>
@endforeach
</div>
@else
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('servers.fail2ban_no_banned') }}</p>
@endif
</div>
@endforeach
{{-- Compact summary; the full jail/banned-IP list (which can grow very
long) lives in a scrollable modal, opened via the button below. --}}
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line px-4 py-3 sm:px-5">
<p class="font-mono text-[11px] text-ink-3">
<span class="text-online">{{ __('common.active') }}</span>
· <span @class(['text-offline' => $f2bBanned > 0])>{{ __('servers.fail2ban_banned', ['count' => $f2bBanned]) }}</span>
· {{ __('servers.fail2ban_jails_count', ['count' => count($f2bJails)]) }}
</p>
<x-btn variant="secondary" size="sm" class="shrink-0"
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-bans', arguments: { serverId: {{ $server->id }} } })">
<x-icon name="lock" class="h-3.5 w-3.5" /> {{ __('servers.fail2ban_view_bans') }}
</x-btn>
</div>
{{-- Whitelist (ignoreip) --}}