diff --git a/app/Livewire/Modals/Fail2banBan.php b/app/Livewire/Modals/Fail2banBan.php new file mode 100644 index 0000000..27f1590 --- /dev/null +++ b/app/Livewire/Modals/Fail2banBan.php @@ -0,0 +1,88 @@ + */ + public array $jails = []; + + public string $jail = ''; + + public string $ip = ''; + + public ?string $error = null; + + /** + * @param array $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'); + } +} diff --git a/app/Livewire/Modals/Fail2banConfig.php b/app/Livewire/Modals/Fail2banConfig.php index 7a41ba3..06e1752 100644 --- a/app/Livewire/Modals/Fail2banConfig.php +++ b/app/Livewire/Modals/Fail2banConfig.php @@ -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'], diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index c45d20b..8397f01 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -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'); diff --git a/app/Services/Fail2banService.php b/app/Services/Fail2banService.php new file mode 100644 index 0000000..9ea93aa --- /dev/null +++ b/app/Services/Fail2banService.php @@ -0,0 +1,427 @@ +detector->detect($server)->supports('fail2ban'); + } + + /** + * Jail status + banned IPs + whitelist in as few round-trips as possible. + * + * @return array + */ + 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} + */ + 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 $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 $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} + */ + 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 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 */ + 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); + } +} diff --git a/app/Services/MaintenanceService.php b/app/Services/MaintenanceService.php index 85e40da..989452c 100644 --- a/app/Services/MaintenanceService.php +++ b/app/Services/MaintenanceService.php @@ -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 { diff --git a/resources/views/livewire/modals/fail2ban-ban.blade.php b/resources/views/livewire/modals/fail2ban-ban.blade.php new file mode 100644 index 0000000..9346c7e --- /dev/null +++ b/resources/views/livewire/modals/fail2ban-ban.blade.php @@ -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 +
+
+ + + +
+

IP manuell sperren

+

+ Sperrt eine IP-Adresse sofort im gewählten Jail. Loopback und der Clusev-Zugang + können nicht gesperrt werden. +

+
+
+ + @if ($error) +
+ +

{{ $error }}

+
+ @endif + +
+
+ + +
+
+ + +
+
+ +
+ Abbrechen + + + IP sperren + +
+
diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index d5ac7ec..f8bf10c 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -335,6 +335,101 @@ @endif + {{-- 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 + + @if ($f2bSupported && $f2bInstalled && $f2bActive) + + + + + + IP sperren + + + @endif + + @if (! $f2bSupported) +
+

{{ $f2b['reason'] ?? 'fail2ban ist auf diesem System nicht verfügbar.' }}

+
+ @elseif ($f2bReadError) +
+ +

fail2ban-Status konnte nicht gelesen werden. Bitte später erneut laden.

+
+ @elseif (! $f2bInstalled || ! $f2bActive) +
+

fail2ban ist {{ $f2bInstalled ? 'installiert, aber inaktiv' : 'nicht installiert' }}.

+ + Aktivieren + +
+ @else +
+ @foreach ($f2bJails as $jail) +
+
+

{{ $jail['name'] }}

+

+ ($jail['currentlyBanned'] ?? 0) > 0])>gebannt: {{ $jail['currentlyBanned'] ?? 0 }} + · Fehlversuche: {{ $jail['currentlyFailed'] ?? 0 }} +

+
+ @if (count($jail['bannedIps'] ?? [])) +
+ @foreach ($jail['bannedIps'] as $ip) +
+ {{ $ip }} + Entsperren +
+ @endforeach +
+ @else +

Keine gebannten IPs.

+ @endif +
+ @endforeach +
+ + {{-- Whitelist (ignoreip) --}} +
+

Whitelist (ignoreip)

+
+ @foreach ($f2bIgnore as $ip) + + {{ $ip }} + @unless (in_array($ip, ['127.0.0.1/8', '::1'], true)) + + @endunless + + @endforeach +
+
+ + Hinzufügen +
+
+ @endif +
+ {{-- Volumes + Netzwerk-Interfaces --}}