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
parent
dc885e293b
commit
8b1bbf401a
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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) --}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue