diff --git a/app/Livewire/Ui/Security/Fail2banSettings.php b/app/Livewire/Ui/Security/Fail2banSettings.php index 38f9b91..390719e 100644 --- a/app/Livewire/Ui/Security/Fail2banSettings.php +++ b/app/Livewire/Ui/Security/Fail2banSettings.php @@ -29,8 +29,10 @@ class Fail2banSettings extends Component #[On('f2b:refresh')] public function refreshLists(): void { - $this->whitelist = Fail2banIpList::where('type', 'whitelist')->pluck('ip')->toArray(); - $this->blacklist = Fail2banIpList::where('type', 'blacklist')->pluck('ip')->toArray(); + $this->whitelist = Fail2banIpList::visibleWhitelist()->pluck('ip')->toArray(); + $this->blacklist = Fail2banIpList::visibleBlacklist()->pluck('ip')->toArray(); +// $this->whitelist = Fail2banIpList::where('type', 'whitelist')->pluck('ip')->toArray(); +// $this->blacklist = Fail2banIpList::where('type', 'blacklist')->pluck('ip')->toArray(); } public function mount(): void diff --git a/app/Livewire/Ui/Security/Modal/Fail2banIpModal.php b/app/Livewire/Ui/Security/Modal/Fail2banIpModal.php index 3fcc01f..c56609f 100644 --- a/app/Livewire/Ui/Security/Modal/Fail2banIpModal.php +++ b/app/Livewire/Ui/Security/Modal/Fail2banIpModal.php @@ -1,5 +1,6 @@ type = $type; - $this->mode = $mode; - $this->ip = $ip ?? ''; + $this->type = $type; + $this->mode = $mode; + $this->ip = $ip ?? ''; $this->prefill = $ip; } @@ -52,46 +56,61 @@ class Fail2banIpModal extends ModalComponent $this->assertAddMode(); $ip = trim($this->ip); - if (!$this->isValidIpOrCidr($ip)) { + if (!Fail2banIpList::isValidIpOrCidr($ip)) { throw ValidationException::withMessages(['ip' => 'Ungültige IP oder CIDR.']); } + // Schutz: System-/Loopback-IPs darf der User nicht manuell pflegen + if (Fail2banIpList::isLoopback($ip)) { + throw ValidationException::withMessages(['ip' => 'Loopback/localhost ist bereits systemseitig erlaubt und kann nicht geändert werden.']); + } + + // Duplikate abfangen (es gibt einen Unique-Index ip+type; trotzdem user-freundlich) + $exists = Fail2banIpList::where('ip', $ip)->where('type', $this->type)->exists(); + if ($exists) { + throw ValidationException::withMessages(['ip' => ucfirst($this->type) . ' enthält diese IP bereits.']); + } + // DB schreiben - Fail2banIpList::firstOrCreate(['ip' => $ip, 'type' => $this->type]); + Fail2banIpList::create(['ip' => $ip, 'type' => $this->type]); if ($this->type === 'whitelist') { - $this->writeWhitelistConfig(); - $this->reloadFail2ban(); + $this->writeWhitelistConfig(); // schreibt /etc/fail2ban/jail.d/mailwolt-whitelist.local + $this->reloadFail2ban(); // f2b neu laden } else { // Blacklist = sofort bannen im dedizierten Jail $this->banIp($ip); } $this->dispatch('f2b:refresh'); - $this->dispatch('notify', message: ucfirst($this->type).' aktualisiert.'); + $this->dispatch('notify', message: ucfirst($this->type) . ' aktualisiert.'); $this->closeModal(); - $this->dispatch('f2b:refresh'); // falls du eine Liste neu laden willst + $this->dispatch('f2b:refresh'); } public function remove(): void { $this->assertRemoveMode(); $ip = trim($this->prefill ?? $this->ip); - if ($ip === '') return; + // System-Whitelist darf nicht entfernt werden + $row = Fail2banIpList::where('type', $this->type)->where('ip', $ip)->first(); + if ($row && $row->is_system) { + throw ValidationException::withMessages(['ip' => 'Systemeintrag kann nicht entfernt werden.']); + } + Fail2banIpList::where('type', $this->type)->where('ip', $ip)->delete(); if ($this->type === 'whitelist') { $this->writeWhitelistConfig(); $this->reloadFail2ban(); } else { - // aus Blacklist-Jail entbannen, falls noch aktiv $this->unbanIp($ip); } $this->dispatch('f2b:refresh'); - $this->dispatch('notify', message: ucfirst($this->type).' Eintrag entfernt.'); + $this->dispatch('notify', message: ucfirst($this->type) . ' Eintrag entfernt.'); $this->closeModal(); $this->dispatch('f2b:refresh'); } @@ -108,53 +127,218 @@ class Fail2banIpModal extends ModalComponent if ($this->mode !== 'remove') throw new \LogicException('Wrong mode'); } - private function isValidIpOrCidr(string $s): bool - { - // IP - if (filter_var($s, FILTER_VALIDATE_IP)) return true; - - // CIDR - if (strpos($s, '/') !== false) { - [$ip, $mask] = explode('/', $s, 2); - if (!filter_var($ip, FILTER_VALIDATE_IP)) return false; - if (strpos($ip, ':') !== false) { - // IPv6 - return ctype_digit($mask) && (int)$mask >= 8 && (int)$mask <= 128; - } - // IPv4 - return ctype_digit($mask) && (int)$mask >= 8 && (int)$mask <= 32; - } - return false; - } - private function writeWhitelistConfig(): void { - $ips = Fail2banIpList::where('type', 'whitelist')->pluck('ip')->toArray(); + // WICHTIG: inkl. System-IPs + $ips = Fail2banIpList::allWhitelistForConfig(); $ignore = implode(' ', array_unique(array_filter($ips))); $content = "[DEFAULT]\nignoreip = {$ignore}\n"; - $file = '/etc/fail2ban/jail.d/mailwolt-whitelist.local'; - $tmp = $file.'.tmp'; - @file_put_contents($tmp, $content, LOCK_EX); - @chmod($tmp, 0644); - @rename($tmp, $file); + // sicher in Root-Pfad schreiben (sudo tee) + $this->writeRootFileViaTee('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content); + } + + private function writeRootFileViaTee(string $target, string $content): void + { + if (!preg_match('#^/etc/fail2ban/jail\.d/[A-Za-z0-9._-]+\.local$#', $target)) { + throw new \RuntimeException("Illegal path: $target"); + } + + $cmd = sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target)); + $desc = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $proc = proc_open($cmd, $desc, $pipes); + if (!is_resource($proc)) { + throw new \RuntimeException('tee start fehlgeschlagen'); + } + fwrite($pipes[0], $content); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + $code = proc_close($proc); + if ($code !== 0) { + throw new \RuntimeException("tee failed (code $code): $stderr $stdout"); + } } private function reloadFail2ban(): void { - @shell_exec('sudo fail2ban-client reload 2>&1'); + @shell_exec('sudo -n /usr/bin/fail2ban-client reload 2>&1'); } private function banIp(string $ip): void { $ipEsc = escapeshellarg($ip); - @shell_exec("sudo fail2ban-client set mailwolt-blacklist banip {$ipEsc} 2>&1"); - // optional: in DB zusätzlich behalten, damit UI konsistent ist (bereits oben getan) + @shell_exec("sudo -n /usr/bin/fail2ban-client set mailwolt-blacklist banip {$ipEsc} 2>&1"); } private function unbanIp(string $ip): void { $ipEsc = escapeshellarg($ip); - @shell_exec("sudo fail2ban-client set mailwolt-blacklist unbanip {$ipEsc} 2>&1"); + @shell_exec("sudo -n /usr/bin/fail2ban-client set mailwolt-blacklist unbanip {$ipEsc} 2>&1"); } } +// +//namespace App\Livewire\Ui\Security\Modal; +// +//use LivewireUI\Modal\ModalComponent; +//use App\Models\Fail2banIpList; +//use Illuminate\Validation\ValidationException; +// +//class Fail2banIpModal extends ModalComponent +//{ +// /** 'whitelist' | 'blacklist' */ +// public string $type = 'whitelist'; +// +// /** 'add' | 'remove' */ +// public string $mode = 'add'; +// +// /** IP/CIDR im Formular */ +// public string $ip = ''; +// +// /** Für "remove" vorbefüllt */ +// public ?string $prefill = null; +// +// public static function modalMaxWidth(): string { return 'lg'; } +// +// public function mount(string $type = 'whitelist', string $mode = 'add', ?string $ip = null): void +// { +// $type = strtolower($type); +// $mode = strtolower($mode); +// +// if (!in_array($type, ['whitelist', 'blacklist'], true)) { +// throw new \InvalidArgumentException('Invalid type'); +// } +// if (!in_array($mode, ['add', 'remove'], true)) { +// throw new \InvalidArgumentException('Invalid mode'); +// } +// +// $this->type = $type; +// $this->mode = $mode; +// $this->ip = $ip ?? ''; +// $this->prefill = $ip; +// } +// +// public function render() +// { +// return view('livewire.ui.security.modal.fail2ban-ip-modal'); +// } +// +// /* ---------------- actions ---------------- */ +// +// public function save(): void +// { +// $this->assertAddMode(); +// $ip = trim($this->ip); +// +// if (!$this->isValidIpOrCidr($ip)) { +// throw ValidationException::withMessages(['ip' => 'Ungültige IP oder CIDR.']); +// } +// +// // DB schreiben +// Fail2banIpList::firstOrCreate(['ip' => $ip, 'type' => $this->type]); +// +// if ($this->type === 'whitelist') { +// $this->writeWhitelistConfig(); +// $this->reloadFail2ban(); +// } else { +// // Blacklist = sofort bannen im dedizierten Jail +// $this->banIp($ip); +// } +// +// $this->dispatch('f2b:refresh'); +// $this->dispatch('notify', message: ucfirst($this->type).' aktualisiert.'); +// $this->closeModal(); +// $this->dispatch('f2b:refresh'); // falls du eine Liste neu laden willst +// } +// +// public function remove(): void +// { +// $this->assertRemoveMode(); +// $ip = trim($this->prefill ?? $this->ip); +// +// if ($ip === '') return; +// +// Fail2banIpList::where('type', $this->type)->where('ip', $ip)->delete(); +// +// if ($this->type === 'whitelist') { +// $this->writeWhitelistConfig(); +// $this->reloadFail2ban(); +// } else { +// // aus Blacklist-Jail entbannen, falls noch aktiv +// $this->unbanIp($ip); +// } +// +// $this->dispatch('f2b:refresh'); +// $this->dispatch('notify', message: ucfirst($this->type).' Eintrag entfernt.'); +// $this->closeModal(); +// $this->dispatch('f2b:refresh'); +// } +// +// /* ---------------- helper ---------------- */ +// +// private function assertAddMode(): void +// { +// if ($this->mode !== 'add') throw new \LogicException('Wrong mode'); +// } +// +// private function assertRemoveMode(): void +// { +// if ($this->mode !== 'remove') throw new \LogicException('Wrong mode'); +// } +// +// private function isValidIpOrCidr(string $s): bool +// { +// // IP +// if (filter_var($s, FILTER_VALIDATE_IP)) return true; +// +// // CIDR +// if (strpos($s, '/') !== false) { +// [$ip, $mask] = explode('/', $s, 2); +// if (!filter_var($ip, FILTER_VALIDATE_IP)) return false; +// if (strpos($ip, ':') !== false) { +// // IPv6 +// return ctype_digit($mask) && (int)$mask >= 8 && (int)$mask <= 128; +// } +// // IPv4 +// return ctype_digit($mask) && (int)$mask >= 8 && (int)$mask <= 32; +// } +// return false; +// } +// +// private function writeWhitelistConfig(): void +// { +// $ips = Fail2banIpList::where('type', 'whitelist')->pluck('ip')->toArray(); +// $ignore = implode(' ', array_unique(array_filter($ips))); +// $content = "[DEFAULT]\nignoreip = {$ignore}\n"; +// +// $file = '/etc/fail2ban/jail.d/mailwolt-whitelist.local'; +// $tmp = $file.'.tmp'; +// @file_put_contents($tmp, $content, LOCK_EX); +// @chmod($tmp, 0644); +// @rename($tmp, $file); +// } +// +// private function reloadFail2ban(): void +// { +// @shell_exec('sudo fail2ban-client reload 2>&1'); +// } +// +// private function banIp(string $ip): void +// { +// $ipEsc = escapeshellarg($ip); +// @shell_exec("sudo fail2ban-client set mailwolt-blacklist banip {$ipEsc} 2>&1"); +// // optional: in DB zusätzlich behalten, damit UI konsistent ist (bereits oben getan) +// } +// +// private function unbanIp(string $ip): void +// { +// $ipEsc = escapeshellarg($ip); +// @shell_exec("sudo fail2ban-client set mailwolt-blacklist unbanip {$ipEsc} 2>&1"); +// } +//} diff --git a/app/Models/Fail2banIpList.php b/app/Models/Fail2banIpList.php index 9393730..8509768 100644 --- a/app/Models/Fail2banIpList.php +++ b/app/Models/Fail2banIpList.php @@ -1,5 +1,6 @@ 'string', 'type' => 'string', + 'is_system' => 'boolean', ]; - const TYPE_WHITELIST = 'whitelist'; - const TYPE_BLACKLIST = 'blacklist'; + public const TYPE_WHITELIST = 'whitelist'; + public const TYPE_BLACKLIST = 'blacklist'; - /** - * Scopes - */ - public function scopeWhitelist($query) + /* =========================== + Boot-Hooks (Schutz & Normalisierung) + =========================== */ + protected static function booted() { - return $query->where('type', self::TYPE_WHITELIST); + // Normalisierung & Loopback-Flag setzen + static::saving(function (self $m) { + $m->ip = trim($m->ip); + + if (!self::isValidIpOrCidr($m->ip)) { + throw new \InvalidArgumentException("Ungültige IP/CIDR: {$m->ip}"); + } + + // Loopback immer als System markieren + if (self::isLoopback($m->ip)) { + $m->is_system = true; + $m->type = self::TYPE_WHITELIST; // Loopback gehört auf die Whitelist + } + + // Systemeinträge dürfen nicht in die Blacklist + if ($m->is_system && $m->type === self::TYPE_BLACKLIST) { + throw new \InvalidArgumentException("Systemeinträge dürfen nicht auf die Blacklist."); + } + }); + + // Systemeinträge sind unveränderlich (bis auf interne Seeds/Maintenance – dann per DB direkt ändern) + static::updating(function (self $m) { + if ($m->getOriginal('is_system')) { + // Erlaube nur no-op Updates (z. B. Timestamps), aber blocke ip/type Änderungen + $blocked = $m->isDirty('ip') || $m->isDirty('type') || $m->isDirty('is_system'); + if ($blocked) { + throw new \RuntimeException("Systemeinträge können nicht geändert werden."); + } + } + }); + + static::deleting(function (self $m) { + if ($m->is_system) { + throw new \RuntimeException("Systemeintrag kann nicht gelöscht werden."); + } + }); } - public function scopeBlacklist($query) + /* =========================== + Scopes + =========================== */ + public function scopeWhitelist($q) { - return $query->where('type', self::TYPE_BLACKLIST); + return $q->where('type', self::TYPE_WHITELIST); } - /** - * Validiert grob die IP. - */ - public function isValidIp(): bool + public function scopeBlacklist($q) { - return filter_var($this->ip, FILTER_VALIDATE_IP) !== false; + return $q->where('type', self::TYPE_BLACKLIST); } - /** - * Gibt Liste aller Whitelist-IPs als Array zurück. - */ + // Für UI: blende Systemeinträge aus + public function scopeVisible($q) + { + return $q->where('is_system', false); + } + + // Kombiniert: z. B. Fail2banIpList::visible()->whitelist()->get(); + public function scopeVisibleWhitelist($q) + { + return $q->visible()->whitelist(); + } + + public function scopeVisibleBlacklist($q) + { + return $q->visible()->blacklist(); + } + + /* =========================== + Helper-Listen + =========================== */ + // Für UI-Listen (ohne System) public static function whitelistArray(): array { - return static::where('type', self::TYPE_WHITELIST)->pluck('ip')->all(); + return static::where('type', self::TYPE_WHITELIST) + ->where('is_system', false) + ->pluck('ip')->all(); } - /** - * Gibt Liste aller Blacklist-IPs als Array zurück. - */ public static function blacklistArray(): array { - return static::where('type', self::TYPE_BLACKLIST)->pluck('ip')->all(); + return static::where('type', self::TYPE_BLACKLIST) + ->where('is_system', false) + ->pluck('ip')->all(); + } + + // Für das Schreiben der Fail2ban-Whitelist-Datei (inkl. System!) + public static function allWhitelistForConfig(): array + { + return static::where('type', self::TYPE_WHITELIST) + ->pluck('ip')->all(); + } + + /* =========================== + Validierung + =========================== */ + // Erlaubt IPv4/IPv6, optional mit CIDR (/0..32 bzw. /0..128) + public static function isValidIpOrCidr(string $value): bool + { + $value = trim($value); + + // IP ohne CIDR + if (filter_var($value, FILTER_VALIDATE_IP)) { + return true; + } + + // IP/CIDR + if (strpos($value, '/') !== false) { + [$ip, $prefix] = explode('/', $value, 2); + if (!ctype_digit($prefix)) { + return false; + } + $prefix = (int)$prefix; + + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $prefix >= 0 && $prefix <= 32; + } + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return $prefix >= 0 && $prefix <= 128; + } + return false; + } + + return false; + } + + // Loopback-Erkennung (IPv4 127.0.0.0/8, IPv6 ::1/128) + public static function isLoopback(string $value): bool + { + $value = trim($value); + + // Klartext-Fälle + if (in_array($value, ['127.0.0.1', '127.0.0.1/8', '::1', '::1/128'], true)) { + return true; + } + + // IPv4 Loopback Bereich + if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return str_starts_with($value, '127.'); + } + + // IPv4-CIDR Loopback + if (strpos($value, '/') !== false) { + [$ip, $prefix] = explode('/', $value, 2); + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && ctype_digit($prefix)) { + $prefix = (int)$prefix; + // Prüfe, ob Netz 127.0.0.0/8 überlappt + return self::cidrOverlaps($ip, $prefix, '127.0.0.0', 8); + } + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) && ctype_digit($prefix)) { + $prefix = (int)$prefix; + // Prüfe, ob ::1/128 überlappt (nur exakt ::1) + return self::cidrOverlaps($ip, $prefix, '::1', 128); + } + } + + // IPv6 single + if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return $value === '::1'; + } + + return false; + } + + // Simple Overlap-Check für IPv4/IPv6 Netze + private static function cidrOverlaps(string $ip, int $prefix, string $netIp, int $netPrefix): bool + { + $a = inet_pton($ip); + $b = inet_pton($netIp); + if ($a === false || $b === false || strlen($a) !== strlen($b)) { + return false; + } + + $len = strlen($a); + $bytes = intdiv(max($prefix, $netPrefix), 8); + $bits = max($prefix, $netPrefix) % 8; + + // Netzmaske anwenden (auf die längere Präfixlänge) + for ($i = 0; $i < $bytes; $i++) { + if ($a[$i] !== $b[$i]) return false; + } + if ($bits > 0) { + $mask = chr(0xFF << (8 - $bits)); + if ((ord($a[$bytes]) & ord($mask)) !== (ord($b[$bytes]) & ord($mask))) { + return false; + } + } + return true; } } +// +//namespace App\Models; +// +//use Illuminate\Database\Eloquent\Model; +// +//class Fail2banIpList extends Model +//{ +// protected $fillable = [ +// 'ip', +// 'type', +// ]; +// +// protected $casts = [ +// 'ip' => 'string', +// 'type' => 'string', +// ]; +// +// const TYPE_WHITELIST = 'whitelist'; +// const TYPE_BLACKLIST = 'blacklist'; +// +// /** +// * Scopes +// */ +// public function scopeWhitelist($query) +// { +// return $query->where('type', self::TYPE_WHITELIST); +// } +// +// public function scopeBlacklist($query) +// { +// return $query->where('type', self::TYPE_BLACKLIST); +// } +// +// /** +// * Validiert grob die IP. +// */ +// public function isValidIp(): bool +// { +// return filter_var($this->ip, FILTER_VALIDATE_IP) !== false; +// } +// +// /** +// * Gibt Liste aller Whitelist-IPs als Array zurück. +// */ +// public static function whitelistArray(): array +// { +// return static::where('type', self::TYPE_WHITELIST)->pluck('ip')->all(); +// } +// +// /** +// * Gibt Liste aller Blacklist-IPs als Array zurück. +// */ +// public static function blacklistArray(): array +// { +// return static::where('type', self::TYPE_BLACKLIST)->pluck('ip')->all(); +// } +//} diff --git a/app/Models/Fail2banSetting.php b/app/Models/Fail2banSetting.php index 106e13a..8f5a5cb 100644 --- a/app/Models/Fail2banSetting.php +++ b/app/Models/Fail2banSetting.php @@ -6,6 +6,8 @@ use Illuminate\Database\Eloquent\Model; class Fail2banSetting extends Model { + protected $table = 'fail2ban_settings'; + protected $fillable = [ 'bantime','max_bantime','bantime_increment','bantime_factor', 'max_retry','findtime','cidr_v4','cidr_v6','external_mode', diff --git a/database/migrations/2025_10_31_150451_create_fail2ban_ip_lists_table.php b/database/migrations/2025_10_31_150451_create_fail2ban_ip_lists_table.php index 54e85dc..7e4e5fa 100644 --- a/database/migrations/2025_10_31_150451_create_fail2ban_ip_lists_table.php +++ b/database/migrations/2025_10_31_150451_create_fail2ban_ip_lists_table.php @@ -15,7 +15,10 @@ return new class extends Migration $table->id(); $table->string('ip'); $table->enum('type', ['whitelist', 'blacklist']); + $table->boolean('is_system')->default(false)->index(); $table->timestamps(); + + $table->unique(['ip', 'type'], 'fail2ban_ip_lists_ip_type_unique'); }); }