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
boban 2026-06-13 20:27:55 +02:00
parent efecd26e31
commit e792e9a3af
7 changed files with 788 additions and 123 deletions

View File

@ -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');
}
}

View File

@ -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'],

View File

@ -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');

View File

@ -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);
}
}

View File

@ -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
{

View File

@ -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>

View File

@ -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">