feat(fail2ban): jail status, banned-IP list + unban, manual ban, whitelist
Addresses "ich sehe nicht welche IPs gebannt sind / entsperren / whitelist". The server-detail page gains a "fail2ban-Status" panel. New Fail2banService (owns all fail2ban I/O): - status(): jails with currently/total failed+banned + the banned IP list, plus the whitelist; sentinel-guarded, and a CLIENT_OK marker so an active service whose fail2ban-client query fails is reported as a read error, not "0 jails". - unban() (idempotent: "not banned" = success), manual ban() GUARDED against loopback (whole 127.0.0.0/8 + ::1) and Clusev's own SSH source (canonical inet_pton compare). - readConfig()/writeTuning()/writeIgnoreip(): tuning and whitelist live in SEPARATE zz- drop-ins and each writes only its own keys, so a whitelist change can never rewrite the ban policy (and vice-versa). The legacy single-file zz-clusev.local is removed on tuning save (migration). ignoreip is preserved VERBATIM (hostnames/CIDRs, continuation lines) — loopback always re-seeded; add/remove report no-ops so the audit only records real changes. UI: jails + banned IPs each with "Entsperren" (direct, audited), whitelist editor (add/remove, audited), "IP sperren" via an R5 modal (modals.fail2ban-ban). Jail/IP args are Js::from()-encoded (remote-sourced, injection-safe). Direct handlers catch SSH failures. Tuning modal migrated to Fail2banService.writeTuning. MaintenanceService slimmed to package updates only (fail2ban moved out). Pint clean; Codex review clean (IPv6 guard, verbatim ignoreip, decoupled drop-ins, legacy migration, read-error detection all hardened); R12 — server-detail HTTP 200, 0 console errors, fail2ban panel + jail + whitelist render. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
efecd26e31
commit
e792e9a3af
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Server;
|
||||
use App\Services\Fail2banService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Manually ban an IP in a fail2ban jail (R5: a deliberate confirm dialog, not a
|
||||
* native popup). Fail2banService refuses to ban loopback or Clusev's own connection,
|
||||
* so this can never lock the operator out.
|
||||
*/
|
||||
class Fail2banBan extends ModalComponent
|
||||
{
|
||||
public int $serverId;
|
||||
|
||||
/** @var array<int, string> */
|
||||
public array $jails = [];
|
||||
|
||||
public string $jail = '';
|
||||
|
||||
public string $ip = '';
|
||||
|
||||
public ?string $error = null;
|
||||
|
||||
/**
|
||||
* @param array<int, string> $jails
|
||||
*/
|
||||
public function mount(int $serverId, array $jails = []): void
|
||||
{
|
||||
$this->serverId = $serverId;
|
||||
$this->jails = array_values($jails);
|
||||
$this->jail = $this->jails[0] ?? 'sshd';
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'lg';
|
||||
}
|
||||
|
||||
public function save(Fail2banService $fail2ban): void
|
||||
{
|
||||
$this->error = null;
|
||||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = $fail2ban->ban($server, $this->jail, trim($this->ip));
|
||||
} catch (Throwable $e) {
|
||||
$this->error = $e->getMessage();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $res['ok']) {
|
||||
$this->error = $res['output'] !== '' ? $res['output'] : 'Sperren fehlgeschlagen.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'server_id' => $server->id,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'fail2ban.ban',
|
||||
'target' => trim($this->ip).' · '.$this->jail.' · '.$server->name,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
$this->dispatch('fail2banChanged');
|
||||
$this->dispatch('notify', message: 'IP '.trim($this->ip).' gesperrt.');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.fail2ban-ban');
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ namespace App\Livewire\Modals;
|
|||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Server;
|
||||
use App\Services\MaintenanceService;
|
||||
use App\Services\Fail2banService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
use Throwable;
|
||||
|
|
@ -33,7 +33,7 @@ class Fail2banConfig extends ModalComponent
|
|||
/** True only once the current policy was read — guards save() from overwriting with unseen defaults. */
|
||||
public bool $loaded = false;
|
||||
|
||||
public function mount(int $serverId, MaintenanceService $maintenance): void
|
||||
public function mount(int $serverId, Fail2banService $fail2ban): void
|
||||
{
|
||||
$this->serverId = $serverId;
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ class Fail2banConfig extends ModalComponent
|
|||
$this->serverName = $server->name;
|
||||
|
||||
try {
|
||||
$current = $maintenance->readFail2ban($server);
|
||||
$current = $fail2ban->readConfig($server);
|
||||
$this->bantime = $current['bantime'];
|
||||
$this->maxretry = $current['maxretry'];
|
||||
$this->findtime = $current['findtime'];
|
||||
|
|
@ -62,7 +62,7 @@ class Fail2banConfig extends ModalComponent
|
|||
return 'lg';
|
||||
}
|
||||
|
||||
public function save(MaintenanceService $maintenance): void
|
||||
public function save(Fail2banService $fail2ban): void
|
||||
{
|
||||
// Never overwrite the remote policy with defaults the operator never saw —
|
||||
// saving is only allowed once the current values were read successfully.
|
||||
|
|
@ -99,8 +99,9 @@ class Fail2banConfig extends ModalComponent
|
|||
}
|
||||
|
||||
try {
|
||||
// Values are already validated/clamped to the allowed ranges above.
|
||||
$result = $maintenance->writeFail2ban(
|
||||
// Tuning lives in its OWN drop-in (separate from the whitelist), so this can
|
||||
// never touch ignoreip. Values were validated/clamped above.
|
||||
$result = $fail2ban->writeTuning(
|
||||
$server,
|
||||
(string) $validated['bantime'],
|
||||
(int) $validated['maxretry'],
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Livewire\Servers;
|
|||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Server;
|
||||
use App\Services\Fail2banService;
|
||||
use App\Services\FirewallService;
|
||||
use App\Services\FleetService;
|
||||
use App\Services\HardeningService;
|
||||
|
|
@ -38,6 +39,12 @@ class Show extends Component
|
|||
/** Firewall state (installed/active/defaults/rules) for the Firewall-Regeln panel. */
|
||||
public array $firewall = [];
|
||||
|
||||
/** fail2ban state (jails/banned IPs/whitelist) for the fail2ban-Status panel. */
|
||||
public array $fail2ban = [];
|
||||
|
||||
/** Input for adding an IP/CIDR to the fail2ban whitelist. */
|
||||
public string $newIgnoreIp = '';
|
||||
|
||||
/** Detected OS tooling (family/package manager/firewall) for the identity panel. */
|
||||
public array $os = [];
|
||||
|
||||
|
|
@ -114,6 +121,13 @@ class Show extends Component
|
|||
} catch (Throwable) {
|
||||
$this->firewall = [];
|
||||
}
|
||||
|
||||
// fail2ban jails/banned IPs/whitelist (separate PRIVILEGED read).
|
||||
try {
|
||||
$this->fail2ban = app(Fail2banService::class)->status($this->server);
|
||||
} catch (Throwable) {
|
||||
$this->fail2ban = [];
|
||||
}
|
||||
$this->loadAvg = (float) ($snap['metrics']['load'] ?? 0);
|
||||
$this->connected = true;
|
||||
} catch (Throwable) {
|
||||
|
|
@ -364,6 +378,110 @@ class Show extends Component
|
|||
}
|
||||
}
|
||||
|
||||
/** Unban a fail2ban IP — recovery action, so a direct (audited) click, no R5 modal. */
|
||||
public function unbanIp(string $jail, string $ip, Fail2banService $fail2ban): void
|
||||
{
|
||||
try {
|
||||
$res = $fail2ban->unban($this->server, $jail, $ip);
|
||||
} catch (Throwable $e) {
|
||||
$this->dispatch('notify', message: 'Entsperren fehlgeschlagen: '.Str::limit($e->getMessage(), 90));
|
||||
|
||||
return;
|
||||
}
|
||||
if (! $res['ok']) {
|
||||
$this->dispatch('notify', message: 'Entsperren fehlgeschlagen: '.Str::limit($res['output'] ?: 'unbekannt', 90));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->audit('fail2ban.unban', $ip.' · '.$jail.' · '.$this->server->name);
|
||||
$this->dispatch('notify', message: "IP {$ip} entsperrt.");
|
||||
$this->reloadFail2ban($fail2ban);
|
||||
}
|
||||
|
||||
/** Add an IP/CIDR to the fail2ban whitelist (ignoreip) — non-destructive, direct + audited. */
|
||||
public function addIgnore(Fail2banService $fail2ban): void
|
||||
{
|
||||
$ip = trim($this->newIgnoreIp);
|
||||
if ($ip === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = $fail2ban->addIgnoreIp($this->server, $ip);
|
||||
} catch (Throwable $e) {
|
||||
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($e->getMessage(), 90));
|
||||
|
||||
return;
|
||||
}
|
||||
if (! $res['ok']) {
|
||||
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($res['output'] ?: 'fehlgeschlagen', 90));
|
||||
|
||||
return;
|
||||
}
|
||||
if (! empty($res['noop'])) {
|
||||
$this->newIgnoreIp = '';
|
||||
$this->dispatch('notify', message: $res['output']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->audit('fail2ban.ignoreip_add', $ip.' · '.$this->server->name);
|
||||
$this->newIgnoreIp = '';
|
||||
$this->dispatch('notify', message: "{$ip} zur Whitelist hinzugefügt.");
|
||||
$this->reloadFail2ban($fail2ban);
|
||||
}
|
||||
|
||||
/** Remove an IP/CIDR from the fail2ban whitelist — direct + audited (loopback is refused by the service). */
|
||||
public function removeIgnore(string $ip, Fail2banService $fail2ban): void
|
||||
{
|
||||
try {
|
||||
$res = $fail2ban->removeIgnoreIp($this->server, $ip);
|
||||
} catch (Throwable $e) {
|
||||
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($e->getMessage(), 90));
|
||||
|
||||
return;
|
||||
}
|
||||
if (! $res['ok']) {
|
||||
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($res['output'] ?: 'fehlgeschlagen', 90));
|
||||
|
||||
return;
|
||||
}
|
||||
if (! empty($res['noop'])) {
|
||||
$this->dispatch('notify', message: $res['output']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->audit('fail2ban.ignoreip_remove', $ip.' · '.$this->server->name);
|
||||
$this->dispatch('notify', message: "{$ip} von der Whitelist entfernt.");
|
||||
$this->reloadFail2ban($fail2ban);
|
||||
}
|
||||
|
||||
/** Re-read fail2ban state after an unban/ban/whitelist change. */
|
||||
#[On('fail2banChanged')]
|
||||
public function reloadFail2ban(Fail2banService $fail2ban): void
|
||||
{
|
||||
try {
|
||||
$this->fail2ban = $fail2ban->status($this->server);
|
||||
} catch (Throwable) {
|
||||
// keep the current state on failure
|
||||
}
|
||||
}
|
||||
|
||||
/** Write one AuditEvent for a fail2ban action (success path only). */
|
||||
private function audit(string $action, string $target): void
|
||||
{
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'server_id' => $this->server->id,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => $action,
|
||||
'target' => $target,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.servers.show');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,427 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Support\Os\OsDetector;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* fail2ban management over SSH (as root): jail status + banned-IP list, unban,
|
||||
* manual ban (lockout-guarded), and the Clusev-managed [DEFAULT] tuning + whitelist.
|
||||
*
|
||||
* Clusev owns EXACTLY ONE file — the zz-clusev.local drop-in — and rewrites the whole
|
||||
* [DEFAULT] block on every change, so the tuning (bantime/findtime/maxretry) and the
|
||||
* whitelist (ignoreip) can never clobber each other. Loopback is always whitelisted.
|
||||
*/
|
||||
class Fail2banService
|
||||
{
|
||||
// TWO 'zz-' drop-ins (sort LAST in jail.d so their [DEFAULT] wins). Tuning and the
|
||||
// whitelist live in SEPARATE files and each writes only its OWN keys, so editing the
|
||||
// whitelist can never rewrite (and clobber) the ban policy, and vice-versa.
|
||||
private const DROPIN_TUNING = '/etc/fail2ban/jail.d/zz-clusev-tuning.local';
|
||||
|
||||
private const DROPIN_IGNOREIP = '/etc/fail2ban/jail.d/zz-clusev-ignoreip.local';
|
||||
|
||||
// The previous single-file drop-in (tuning only). It sorts AFTER the new split files,
|
||||
// so it must be removed on migration or its stale [DEFAULT] would override new tuning.
|
||||
private const LEGACY_DROPIN = '/etc/fail2ban/jail.d/zz-clusev.local';
|
||||
|
||||
// Always whitelisted so the operator can never lock fail2ban against localhost.
|
||||
private const LOOPBACK = ['127.0.0.1/8', '::1'];
|
||||
|
||||
public function __construct(private FleetService $fleet, private OsDetector $detector) {}
|
||||
|
||||
/** NULL when fail2ban is supported on this host, else a German reason. */
|
||||
public function support(Server $server): ?string
|
||||
{
|
||||
return $this->detector->detect($server)->supports('fail2ban');
|
||||
}
|
||||
|
||||
/**
|
||||
* Jail status + banned IPs + whitelist in as few round-trips as possible.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function status(Server $server): array
|
||||
{
|
||||
$reason = $this->support($server);
|
||||
$base = ['supported' => $reason === null, 'reason' => $reason, 'installed' => false, 'active' => false, 'jails' => [], 'ignoreip' => []];
|
||||
if ($reason !== null) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$cmd = 'echo CLUSEV_F2B_OK; '
|
||||
.'command -v fail2ban-client >/dev/null 2>&1 && echo INST=yes || echo INST=no; '
|
||||
.'systemctl is-active --quiet fail2ban && echo ACT=active || echo ACT=inactive; '
|
||||
// CLIENT_OK only when `fail2ban-client status` itself SUCCEEDS — so an active
|
||||
// service whose client call fails is not misreported as "0 jails".
|
||||
.'cs=$(fail2ban-client status 2>/dev/null) && echo CLIENT_OK; '
|
||||
.'jails=$(printf "%s" "$cs" | sed -n "s/.*Jail list:[[:space:]]*//p" | tr "," " "); '
|
||||
.'for j in $jails; do echo ===CLUSEV:jail:$j===; fail2ban-client status "$j" 2>/dev/null; done';
|
||||
|
||||
$res = $this->fleet->runPrivileged($server, $cmd);
|
||||
if (! str_contains($res['output'], 'CLUSEV_F2B_OK')) {
|
||||
return $base + ['readError' => true];
|
||||
}
|
||||
|
||||
$out = $res['output'];
|
||||
$installed = (bool) preg_match('/^INST=yes$/m', $out);
|
||||
$active = (bool) preg_match('/^ACT=active$/m', $out);
|
||||
|
||||
// Service active but the client query failed -> a real read error, not "no jails".
|
||||
if ($installed && $active && ! preg_match('/^CLIENT_OK$/m', $out)) {
|
||||
return $base + ['readError' => true];
|
||||
}
|
||||
|
||||
$jails = [];
|
||||
foreach ($this->jailSections($out) as $name => $body) {
|
||||
$jails[] = [
|
||||
'name' => $name,
|
||||
'currentlyFailed' => $this->intField($body, 'Currently failed'),
|
||||
'totalFailed' => $this->intField($body, 'Total failed'),
|
||||
'currentlyBanned' => $this->intField($body, 'Currently banned'),
|
||||
'totalBanned' => $this->intField($body, 'Total banned'),
|
||||
'bannedIps' => $this->bannedIps($body),
|
||||
];
|
||||
}
|
||||
|
||||
// ignoreip is managed separately (our drop-in); read it best-effort.
|
||||
$ignoreip = [];
|
||||
try {
|
||||
$ignoreip = $this->readConfig($server)['ignoreip'];
|
||||
} catch (RuntimeException) {
|
||||
// leave empty on read failure
|
||||
}
|
||||
|
||||
return ['supported' => true, 'reason' => null, 'installed' => $installed, 'active' => $active, 'jails' => $jails, 'ignoreip' => $ignoreip];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the effective [DEFAULT] tuning + whitelist (last value wins across files).
|
||||
*
|
||||
* @return array{bantime: string, maxretry: int, findtime: string, ignoreip: array<int, string>}
|
||||
*/
|
||||
public function readConfig(Server $server): array
|
||||
{
|
||||
// A leading sentinel proves the read RAN (sudo/SSH ok); a CLUSEV_RESET marker
|
||||
// stops a section bleeding across file boundaries. Only [DEFAULT] keys count.
|
||||
$cmd = 'echo CLUSEV_READ_OK; for f in /etc/fail2ban/jail.conf /etc/fail2ban/jail.d/*.conf '
|
||||
.'/etc/fail2ban/jail.local /etc/fail2ban/jail.d/*.local; do '
|
||||
.'[ -f "$f" ] && { echo "[CLUSEV_RESET]"; cat "$f"; }; done 2>/dev/null';
|
||||
|
||||
$res = $this->fleet->runPrivileged($server, $cmd);
|
||||
if (! str_contains($res['output'], 'CLUSEV_READ_OK')) {
|
||||
throw new RuntimeException('fail2ban-Konfiguration konnte nicht gelesen werden.');
|
||||
}
|
||||
|
||||
return $this->parseDefaults($res['output']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write ONLY the [DEFAULT] tuning keys to the tuning drop-in (never ignoreip), so a
|
||||
* tuning change can't touch the whitelist. Values must already be validated/clamped.
|
||||
*
|
||||
* @return array{ok: bool, output: string}
|
||||
*/
|
||||
public function writeTuning(Server $server, string $bantime, int $maxretry, string $findtime): array
|
||||
{
|
||||
$bantime = trim(preg_replace('/\s+/', ' ', $bantime) ?? '');
|
||||
$findtime = trim(preg_replace('/\s+/', ' ', $findtime) ?? '');
|
||||
|
||||
$content = "# Managed by Clusev — fail2ban tuning. Do not edit by hand.\n"
|
||||
."[DEFAULT]\n"
|
||||
."bantime = {$bantime}\n"
|
||||
."findtime = {$findtime}\n"
|
||||
."maxretry = {$maxretry}\n";
|
||||
|
||||
// Drop the legacy single-file drop-in (tuning only) so it can't override this.
|
||||
return $this->writeDropin($server, self::DROPIN_TUNING, $content, [self::LEGACY_DROPIN]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write ONLY the [DEFAULT] ignoreip to the whitelist drop-in (never tuning), so a
|
||||
* whitelist change can't touch the ban policy. Loopback is always re-seeded.
|
||||
*
|
||||
* @param array<int, string> $ignoreip
|
||||
* @return array{ok: bool, output: string}
|
||||
*/
|
||||
public function writeIgnoreip(Server $server, array $ignoreip): array
|
||||
{
|
||||
$ignoreip = $this->normalizeIgnoreip($ignoreip);
|
||||
|
||||
$content = "# Managed by Clusev — fail2ban whitelist. Do not edit by hand.\n"
|
||||
."[DEFAULT]\n"
|
||||
.'ignoreip = '.implode(' ', $ignoreip)."\n";
|
||||
|
||||
return $this->writeDropin($server, self::DROPIN_IGNOREIP, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write one drop-in (base64-safe), optionally removing stale files first,
|
||||
* then reload fail2ban if it is running.
|
||||
*
|
||||
* @param array<int, string> $removeFirst
|
||||
*/
|
||||
private function writeDropin(Server $server, string $path, string $content, array $removeFirst = []): array
|
||||
{
|
||||
$rm = '';
|
||||
foreach ($removeFirst as $stale) {
|
||||
$rm .= 'rm -f '.$stale.'; ';
|
||||
}
|
||||
|
||||
$b64 = base64_encode($content);
|
||||
$cmd = 'mkdir -p /etc/fail2ban/jail.d; '.$rm
|
||||
.'printf %s '.$b64.' | base64 -d > '.$path
|
||||
.' && if systemctl is-active --quiet fail2ban; then systemctl reload fail2ban || fail2ban-client reload; fi';
|
||||
|
||||
$res = $this->fleet->runPrivileged($server, $cmd, 120);
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $this->tail($res['output'])];
|
||||
}
|
||||
|
||||
/** Unban one IP from a jail. "is not banned" counts as success (idempotent). */
|
||||
public function unban(Server $server, string $jail, string $ip): array
|
||||
{
|
||||
if (! $this->validJail($jail) || ! filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return ['ok' => false, 'output' => 'Ungültige Eingabe.'];
|
||||
}
|
||||
if ($reason = $this->support($server)) {
|
||||
return ['ok' => false, 'output' => $reason];
|
||||
}
|
||||
|
||||
$res = $this->fleet->runPrivileged($server, 'fail2ban-client set '.$jail.' unbanip '.$ip);
|
||||
$ok = $res['ok'] || stripos($res['output'], 'not banned') !== false;
|
||||
|
||||
return ['ok' => $ok, 'output' => $this->tail($res['output'])];
|
||||
}
|
||||
|
||||
/** Manually ban an IP in a jail. GUARDED against banning Clusev's own connection. */
|
||||
public function ban(Server $server, string $jail, string $ip): array
|
||||
{
|
||||
if (! $this->validJail($jail) || ! filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return ['ok' => false, 'output' => 'Ungültige Eingabe.'];
|
||||
}
|
||||
if ($reason = $this->support($server)) {
|
||||
return ['ok' => false, 'output' => $reason];
|
||||
}
|
||||
|
||||
// LOCKOUT GUARD: never ban loopback (the WHOLE 127.0.0.0/8 range + ::1) or the
|
||||
// address Clusev connects FROM. Compare CANONICALLY (inet_pton) so an alternate
|
||||
// IPv6 spelling cannot slip past.
|
||||
$cp = $this->controlPlaneIp($server);
|
||||
$loopback = $this->sameIp($ip, '::1')
|
||||
|| (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false && str_starts_with($ip, '127.'));
|
||||
if ($loopback || ($cp !== null && $this->sameIp($ip, $cp))) {
|
||||
return ['ok' => false, 'output' => 'Diese IP gehört zum Clusev-Zugang und kann nicht gesperrt werden (Aussperrgefahr).'];
|
||||
}
|
||||
|
||||
$res = $this->fleet->runPrivileged($server, 'fail2ban-client set '.$jail.' banip '.$ip);
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $this->tail($res['output'])];
|
||||
}
|
||||
|
||||
/** Add an IP/CIDR to the whitelist (ignoreip), preserving the tuning values. */
|
||||
public function addIgnoreIp(Server $server, string $ip): array
|
||||
{
|
||||
$ip = trim($ip);
|
||||
if (! $this->validIpOrCidr($ip)) {
|
||||
return ['ok' => false, 'output' => 'Ungültige IP-Adresse oder CIDR.'];
|
||||
}
|
||||
if ($reason = $this->support($server)) {
|
||||
return ['ok' => false, 'output' => $reason];
|
||||
}
|
||||
|
||||
$cfg = $this->readConfig($server);
|
||||
if (in_array($ip, $cfg['ignoreip'], true)) {
|
||||
return ['ok' => true, 'noop' => true, 'output' => 'Bereits auf der Whitelist.'];
|
||||
}
|
||||
$list = $cfg['ignoreip'];
|
||||
$list[] = $ip;
|
||||
|
||||
return $this->writeIgnoreip($server, $list);
|
||||
}
|
||||
|
||||
/** Remove an IP/CIDR from the whitelist (loopback can never be removed). */
|
||||
public function removeIgnoreIp(Server $server, string $ip): array
|
||||
{
|
||||
$ip = trim($ip);
|
||||
if ($reason = $this->support($server)) {
|
||||
return ['ok' => false, 'output' => $reason];
|
||||
}
|
||||
if (in_array($ip, self::LOOPBACK, true)) {
|
||||
return ['ok' => false, 'output' => 'Loopback bleibt immer auf der Whitelist.'];
|
||||
}
|
||||
|
||||
$cfg = $this->readConfig($server);
|
||||
if (! in_array($ip, $cfg['ignoreip'], true)) {
|
||||
return ['ok' => true, 'noop' => true, 'output' => 'Nicht auf der Whitelist.'];
|
||||
}
|
||||
$list = array_values(array_filter($cfg['ignoreip'], fn (string $e): bool => $e !== $ip));
|
||||
|
||||
return $this->writeIgnoreip($server, $list);
|
||||
}
|
||||
|
||||
/** Canonical IP equality (normalizes IPv6 spellings via inet_pton). */
|
||||
private function sameIp(string $a, string $b): bool
|
||||
{
|
||||
$pa = @inet_pton($a);
|
||||
$pb = @inet_pton($b);
|
||||
|
||||
return $pa !== false && $pb !== false && $pa === $pb;
|
||||
}
|
||||
|
||||
/** Clusev's source IP as the server sees it (from $SSH_CONNECTION), or null. */
|
||||
private function controlPlaneIp(Server $server): ?string
|
||||
{
|
||||
$res = $this->fleet->runPlain($server, 'printf %s "$SSH_CONNECTION"');
|
||||
$ip = $res['ok'] ? (preg_split('/\s+/', trim($res['output']))[0] ?? '') : '';
|
||||
|
||||
return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{bantime: string, maxretry: int, findtime: string, ignoreip: array<int, string>}
|
||||
*/
|
||||
private function parseDefaults(string $body): array
|
||||
{
|
||||
$vals = ['bantime' => '10m', 'maxretry' => 5, 'findtime' => '10m', 'ignoreip' => self::LOOPBACK];
|
||||
$inDefault = false;
|
||||
$cont = false; // currently inside an ignoreip continuation
|
||||
$ignoreRaw = null; // raw ignoreip value (across continuation lines), last file wins
|
||||
|
||||
foreach (preg_split('/\R/', $body) ?: [] as $raw) {
|
||||
$t = trim($raw);
|
||||
if ($t === '' || $t[0] === '#' || $t[0] === ';') {
|
||||
$cont = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
// Section header (incl. the [CLUSEV_RESET] between files) resets state.
|
||||
if (preg_match('/^\[([^\]]+)\]/', $t, $m)) {
|
||||
$inDefault = strcasecmp(trim($m[1]), 'DEFAULT') === 0;
|
||||
$cont = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $inDefault) {
|
||||
$cont = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
// INI continuation: an indented line right after ignoreip extends its value.
|
||||
if ($cont && preg_match('/^\s/', $raw)) {
|
||||
$ignoreRaw .= ' '.$t;
|
||||
|
||||
continue;
|
||||
}
|
||||
$cont = false;
|
||||
|
||||
if (preg_match('/^bantime\s*=\s*(.+?)\s*$/i', $t, $m)) {
|
||||
$vals['bantime'] = $m[1];
|
||||
} elseif (preg_match('/^findtime\s*=\s*(.+?)\s*$/i', $t, $m)) {
|
||||
$vals['findtime'] = $m[1];
|
||||
} elseif (preg_match('/^maxretry\s*=\s*(\d+)/i', $t, $m)) {
|
||||
$vals['maxretry'] = (int) $m[1];
|
||||
} elseif (preg_match('/^ignoreip\s*=\s*(.*)$/i', $t, $m)) {
|
||||
$ignoreRaw = $m[1]; // last assignment (highest-precedence file) wins
|
||||
$cont = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($ignoreRaw !== null) {
|
||||
$vals['ignoreip'] = $this->normalizeIgnoreip(preg_split('/\s+/', trim($ignoreRaw)) ?: []);
|
||||
}
|
||||
|
||||
return $vals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loopback first, then every existing entry VERBATIM (deduped) — hostnames, CIDRs
|
||||
* and other fail2ban-valid tokens are preserved, never dropped (new entries from
|
||||
* the UI are validated separately before they reach this list).
|
||||
*/
|
||||
private function normalizeIgnoreip(array $list): array
|
||||
{
|
||||
$out = self::LOOPBACK;
|
||||
foreach ($list as $entry) {
|
||||
$entry = trim((string) $entry);
|
||||
if ($entry !== '' && ! in_array($entry, $out, true)) {
|
||||
$out[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string, string> jail name => status body */
|
||||
private function jailSections(string $out): array
|
||||
{
|
||||
$sections = [];
|
||||
$current = null;
|
||||
$buffer = [];
|
||||
foreach (preg_split('/\R/', $out) ?: [] as $line) {
|
||||
if (str_starts_with($line, '===CLUSEV:jail:')) {
|
||||
if ($current !== null) {
|
||||
$sections[$current] = implode("\n", $buffer);
|
||||
}
|
||||
$current = rtrim(substr($line, strlen('===CLUSEV:jail:')), '=');
|
||||
$buffer = [];
|
||||
} elseif ($current !== null) {
|
||||
$buffer[] = $line;
|
||||
}
|
||||
}
|
||||
if ($current !== null) {
|
||||
$sections[$current] = implode("\n", $buffer);
|
||||
}
|
||||
|
||||
return $sections;
|
||||
}
|
||||
|
||||
private function intField(string $body, string $label): int
|
||||
{
|
||||
return preg_match('/'.preg_quote($label, '/').':\s*(\d+)/i', $body, $m) ? (int) $m[1] : 0;
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
private function bannedIps(string $body): array
|
||||
{
|
||||
if (! preg_match('/Banned IP list:\s*(.*)$/im', $body, $m)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
preg_split('/\s+/', trim($m[1])) ?: [],
|
||||
fn (string $ip): bool => (bool) filter_var($ip, FILTER_VALIDATE_IP)
|
||||
));
|
||||
}
|
||||
|
||||
private function validJail(string $jail): bool
|
||||
{
|
||||
return (bool) preg_match('/^[A-Za-z0-9._-]+$/', $jail);
|
||||
}
|
||||
|
||||
private function validIpOrCidr(string $v): bool
|
||||
{
|
||||
if (filter_var($v, FILTER_VALIDATE_IP)) {
|
||||
return true;
|
||||
}
|
||||
if (! str_contains($v, '/')) {
|
||||
return false;
|
||||
}
|
||||
[$ip, $mask] = explode('/', $v, 2);
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP) || ! ctype_digit($mask)) {
|
||||
return false;
|
||||
}
|
||||
$max = str_contains($ip, ':') ? 128 : 32;
|
||||
|
||||
return (int) $mask >= 0 && (int) $mask <= $max;
|
||||
}
|
||||
|
||||
private function tail(string $out, int $max = 300): string
|
||||
{
|
||||
$out = trim($out);
|
||||
|
||||
return mb_strlen($out) <= $max ? $out : '…'.mb_substr($out, -$max);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,24 +5,16 @@ namespace App\Services;
|
|||
use App\Models\Server;
|
||||
use App\Support\Os\OsDetector;
|
||||
use App\Support\Os\PackageManager;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Package-maintenance helpers across apt/dnf/zypper (resolved per OS via
|
||||
* OsDetector): counts pending package updates, applies an upgrade, and reads/writes
|
||||
* the fail2ban [DEFAULT] tuning.
|
||||
* OsDetector): counts pending package updates and applies an upgrade.
|
||||
*
|
||||
* Every reader/writer goes through FleetService, so quoting is base64-safe and
|
||||
* privileged work runs as root. Callers must clamp the fail2ban integers before
|
||||
* calling writeFail2ban(); this service only persists already-validated values.
|
||||
* privileged work runs as root. (fail2ban tuning + management lives in Fail2banService.)
|
||||
*/
|
||||
class MaintenanceService
|
||||
{
|
||||
// A Clusev-owned drop-in. 'zz-' so it sorts LAST in jail.d and its [DEFAULT] wins
|
||||
// (fail2ban applies the last value). We only ever write/read THIS file — never the
|
||||
// operator's jail.local or jail definitions.
|
||||
private const FAIL2BAN_DROPIN = '/etc/fail2ban/jail.d/zz-clusev.local';
|
||||
|
||||
public function __construct(private FleetService $fleet, private OsDetector $detector) {}
|
||||
|
||||
/**
|
||||
|
|
@ -74,113 +66,6 @@ class MaintenanceService
|
|||
return ['ok' => $res['ok'], 'output' => $this->tail($res['output'], 400)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Clusev-managed fail2ban [DEFAULT] tuning from our OWN drop-in only —
|
||||
* we never read the operator's jail.local or per-jail sections, so a jail's
|
||||
* `maxretry` can't masquerade as the global default. Defaults when unset.
|
||||
*
|
||||
* @return array{bantime: int, maxretry: int, findtime: int}
|
||||
*/
|
||||
public function readFail2ban(Server $server): array
|
||||
{
|
||||
// Read the [DEFAULT] section across all fail2ban files in fail2ban's own
|
||||
// precedence order (last value wins). A CLUSEV_RESET marker between files
|
||||
// stops a section bleeding across file boundaries; only [DEFAULT] keys count
|
||||
// (a jail's maxretry must not masquerade as the global default).
|
||||
// A leading sentinel proves the read actually RAN (sudo/SSH ok). We key success
|
||||
// off the sentinel — NOT the loop's exit code — so a final unmatched glob (a
|
||||
// benign "no drop-ins" case) does not look like a failure, while a genuine
|
||||
// sudo/permission/SSH error (no sentinel echoed) IS propagated as a throw and can
|
||||
// never be downgraded to defaults the operator might then overwrite.
|
||||
$cmd = 'echo CLUSEV_READ_OK; for f in /etc/fail2ban/jail.conf /etc/fail2ban/jail.d/*.conf '
|
||||
.'/etc/fail2ban/jail.local /etc/fail2ban/jail.d/*.local; do '
|
||||
.'[ -f "$f" ] && { echo "[CLUSEV_RESET]"; cat "$f"; }; done 2>/dev/null';
|
||||
|
||||
$res = $this->fleet->runPrivileged($server, $cmd);
|
||||
if (! str_contains($res['output'], 'CLUSEV_READ_OK')) {
|
||||
throw new RuntimeException('fail2ban-Konfiguration konnte nicht gelesen werden.');
|
||||
}
|
||||
|
||||
return $this->parseFail2banDefaults($res['output']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Section-aware parse of the effective [DEFAULT] tuning (last value wins),
|
||||
* converting fail2ban time units (s/m/h/d/w) to seconds.
|
||||
*
|
||||
* @return array{bantime: int, maxretry: int, findtime: int}
|
||||
*/
|
||||
private function parseFail2banDefaults(string $body): array
|
||||
{
|
||||
// bantime/findtime are kept VERBATIM — fail2ban's native duration grammar
|
||||
// (600, 10m, 1h 30m, -1 for permanent, …) is preserved, never lossily converted
|
||||
// to seconds. maxretry is a plain integer.
|
||||
$vals = ['bantime' => '10m', 'maxretry' => 5, 'findtime' => '10m'];
|
||||
$inDefault = false;
|
||||
|
||||
foreach (preg_split('/\R/', $body) ?: [] as $line) {
|
||||
$t = trim($line);
|
||||
if ($t === '' || $t[0] === '#' || $t[0] === ';') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^\[([^\]]+)\]/', $t, $m)) {
|
||||
$inDefault = strcasecmp(trim($m[1]), 'DEFAULT') === 0;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $inDefault) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^bantime\s*=\s*(.+?)\s*$/i', $t, $m)) {
|
||||
$vals['bantime'] = $m[1];
|
||||
} elseif (preg_match('/^findtime\s*=\s*(.+?)\s*$/i', $t, $m)) {
|
||||
$vals['findtime'] = $m[1];
|
||||
} elseif (preg_match('/^maxretry\s*=\s*(\d+)/i', $t, $m)) {
|
||||
$vals['maxretry'] = (int) $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
return $vals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the Clusev [DEFAULT] block to our OWN jail.d drop-in (overwriting only
|
||||
* that file — the operator's jail.local and jail definitions are untouched),
|
||||
* then reload (fallback restart) fail2ban. The integers MUST already be clamped
|
||||
* by the caller; cast here as a last line of defence.
|
||||
*
|
||||
* @return array{ok: bool, output: string}
|
||||
*/
|
||||
public function writeFail2ban(Server $server, string $bantime, int $maxretry, string $findtime): array
|
||||
{
|
||||
$maxretry = (int) $maxretry;
|
||||
// Durations stay in fail2ban's native grammar (validated by the caller); collapse
|
||||
// any stray whitespace/newlines so the written ini line stays well-formed.
|
||||
$bantime = trim(preg_replace('/\s+/', ' ', $bantime) ?? '');
|
||||
$findtime = trim(preg_replace('/\s+/', ' ', $findtime) ?? '');
|
||||
|
||||
// Build the config with base64 so it never touches the shell unquoted; the
|
||||
// whole command is itself base64-wrapped again by runPrivileged.
|
||||
$content = "# Managed by Clusev — do not edit by hand.\n"
|
||||
."[DEFAULT]\n"
|
||||
."bantime = {$bantime}\n"
|
||||
."findtime = {$findtime}\n"
|
||||
."maxretry = {$maxretry}\n";
|
||||
|
||||
$b64 = base64_encode($content);
|
||||
|
||||
// Write the drop-in; reload ONLY if fail2ban is already running (never start an
|
||||
// inactive service from the settings form). A reload failure while active IS
|
||||
// propagated (the `if` returns reload's exit code); inactive succeeds silently.
|
||||
$cmd = 'mkdir -p /etc/fail2ban/jail.d'
|
||||
.' && printf %s '.$b64.' | base64 -d > '.self::FAIL2BAN_DROPIN
|
||||
.' && if systemctl is-active --quiet fail2ban; then systemctl reload fail2ban; fi';
|
||||
|
||||
$res = $this->fleet->runPrivileged($server, $cmd, 120);
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $this->tail($res['output'], 400)];
|
||||
}
|
||||
|
||||
/** Keep only the last $max characters of a (multi-line) output. */
|
||||
private function tail(string $out, int $max): string
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
@php
|
||||
$label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
|
||||
$field = 'h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
|
||||
$select = 'h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink focus:border-accent/40 focus:outline-none';
|
||||
@endphp
|
||||
<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">IP manuell sperren</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">
|
||||
Sperrt eine IP-Adresse sofort im gewählten Jail. Loopback und der Clusev-Zugang
|
||||
können nicht gesperrt werden.
|
||||
</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 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="{{ $label }}">Jail</label>
|
||||
<select wire:model="jail" class="{{ $select }}">
|
||||
@forelse ($jails as $j)
|
||||
<option value="{{ $j }}">{{ $j }}</option>
|
||||
@empty
|
||||
<option value="sshd">sshd</option>
|
||||
@endforelse
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="{{ $label }}">IP-Adresse</label>
|
||||
<input wire:model="ip" type="text" placeholder="z. B. 203.0.113.10" class="{{ $field }}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
|
||||
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled">
|
||||
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||
IP sperren
|
||||
</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -335,6 +335,101 @@
|
|||
@endif
|
||||
</x-panel>
|
||||
|
||||
{{-- fail2ban-Status --}}
|
||||
@php
|
||||
$f2b = $fail2ban ?? [];
|
||||
$f2bSupported = $f2b['supported'] ?? false;
|
||||
$f2bReadError = $f2b['readError'] ?? false;
|
||||
$f2bInstalled = $f2b['installed'] ?? false;
|
||||
$f2bActive = $f2b['active'] ?? false;
|
||||
$f2bJails = $f2b['jails'] ?? [];
|
||||
$f2bIgnore = $f2b['ignoreip'] ?? [];
|
||||
$f2bBanned = array_sum(array_map(fn ($j) => $j['currentlyBanned'] ?? 0, $f2bJails));
|
||||
@endphp
|
||||
<x-panel title="fail2ban-Status"
|
||||
:subtitle="$f2bSupported ? ($f2bInstalled ? ($f2bActive ? ('aktiv · ' . $f2bBanned . ' gesperrt') : 'installiert · inaktiv') : 'nicht installiert') : 'Nicht verfügbar'"
|
||||
:padded="false">
|
||||
@if ($f2bSupported && $f2bInstalled && $f2bActive)
|
||||
<x-slot:actions>
|
||||
<x-btn variant="ghost" size="sm" icon title="fail2ban konfigurieren"
|
||||
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-config', arguments: { serverId: {{ $server->id }} } })">
|
||||
<x-icon name="settings" class="h-3.5 w-3.5" />
|
||||
</x-btn>
|
||||
<x-btn variant="accent" size="sm"
|
||||
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-ban', arguments: { serverId: {{ $server->id }}, jails: {{ \Illuminate\Support\Js::from(array_map(fn ($j) => $j['name'], $f2bJails)) }} } })">
|
||||
<x-icon name="lock" class="h-3.5 w-3.5" /> IP sperren
|
||||
</x-btn>
|
||||
</x-slot:actions>
|
||||
@endif
|
||||
|
||||
@if (! $f2bSupported)
|
||||
<div class="px-4 py-4 sm:px-5">
|
||||
<p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ $f2b['reason'] ?? 'fail2ban ist auf diesem System nicht verfügbar.' }}</p>
|
||||
</div>
|
||||
@elseif ($f2bReadError)
|
||||
<div class="flex flex-wrap items-center gap-2.5 px-4 py-4 sm:px-5">
|
||||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
|
||||
<p class="font-mono text-[11px] text-ink-3">fail2ban-Status konnte nicht gelesen werden. Bitte später erneut laden.</p>
|
||||
</div>
|
||||
@elseif (! $f2bInstalled || ! $f2bActive)
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
|
||||
<p class="font-mono text-[11px] text-ink-3">fail2ban ist {{ $f2bInstalled ? 'installiert, aber inaktiv' : 'nicht installiert' }}.</p>
|
||||
<x-btn variant="secondary" size="sm"
|
||||
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: 'fail2ban', enable: true } })">
|
||||
Aktivieren
|
||||
</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])>gebannt: {{ $jail['currentlyBanned'] ?? 0 }}</span>
|
||||
· Fehlversuche: {{ $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="font-mono text-[11px] text-ink-2">{{ $ip }}</span>
|
||||
<x-btn variant="ghost" size="sm" wire:click="unbanIp({{ \Illuminate\Support\Js::from($jail['name']) }}, {{ \Illuminate\Support\Js::from($ip) }})">Entsperren</x-btn>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-4">Keine gebannten IPs.</p>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Whitelist (ignoreip) --}}
|
||||
<div class="border-t border-line px-4 py-3 sm:px-5">
|
||||
<p class="mb-2 font-mono text-[10px] uppercase tracking-wider text-ink-4">Whitelist (ignoreip)</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach ($f2bIgnore as $ip)
|
||||
<span class="inline-flex items-center gap-1.5 rounded-sm border border-line bg-inset px-2 py-1 font-mono text-[11px] text-ink-2">
|
||||
{{ $ip }}
|
||||
@unless (in_array($ip, ['127.0.0.1/8', '::1'], true))
|
||||
<button type="button" wire:click="removeIgnore({{ \Illuminate\Support\Js::from($ip) }})" class="text-ink-4 hover:text-offline" title="Entfernen">
|
||||
<x-icon name="x" class="h-3 w-3" />
|
||||
</button>
|
||||
@endunless
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="mt-2.5 flex items-center gap-2">
|
||||
<input wire:model="newIgnoreIp" wire:keydown.enter="addIgnore" type="text" placeholder="IP oder CIDR"
|
||||
class="h-8 w-full max-w-xs rounded-md border border-line bg-inset px-2.5 font-mono text-[11px] text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
<x-btn variant="secondary" size="sm" wire:click="addIgnore">Hinzufügen</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</x-panel>
|
||||
|
||||
{{-- Volumes + Netzwerk-Interfaces --}}
|
||||
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<x-panel title="Volumes" :subtitle="count($volumes) . ' Mountpoints'" :padded="false">
|
||||
|
|
|
|||
Loading…
Reference in New Issue