Compare commits

...

17 Commits

Author SHA1 Message Date
boban af369acbf6 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-08 12:57:05 +01:00
boban d81c3bc07c Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-08 12:52:54 +01:00
boban 821a2bde33 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-08 12:43:52 +01:00
boban 8e68051fde Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-04 18:40:39 +01:00
boban afb8d09db3 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 23:20:53 +01:00
boban fc04ef44d0 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 23:17:06 +01:00
boban a7d84899fb Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 22:58:18 +01:00
boban e3dc81ef73 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 22:53:37 +01:00
boban 9acea7b89b Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 22:20:53 +01:00
boban 6c3cde5f65 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 22:10:01 +01:00
boban 77f22518c8 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 22:05:14 +01:00
boban 9aa9475387 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 18:17:27 +01:00
boban d4255b08fa Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 17:43:14 +01:00
boban 94aec78d4c Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 16:52:41 +01:00
boban d3783e1717 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 16:41:54 +01:00
boban dcf9a8d3e9 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 04:32:24 +01:00
boban 595828c5f6 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 04:29:30 +01:00
40 changed files with 2500 additions and 2092 deletions

0
,
View File

0
0
View File

View File

@ -1,9 +0,0 @@
= App\Models\Setting {#6409
id: 13,
group: "woltguard",
key: "services",
value: "{"ts":1761504019,"rows":[{"name":"postfix","ok":true},{"name":"dovecot","ok":true},{"name":"rspamd","ok":true},{"name":"clamav","ok":true},{"name":"db","ok":true},{"name":"redis","ok":true},{"name":"php-fpm","ok":true},{"name":"nginx","ok":true},{"name":"mw-queue","ok":true},{"name":"mw-schedule","ok":true},{"name":"mw-ws","ok":true},{"name":"fail2ban","ok":true},{"name":"journal","ok":true}]}",
created_at: "2025-10-26 19:40:19",
updated_at: "2025-10-26 19:40:19",
}

View File

View File

View File

@ -31,7 +31,6 @@ if (!function_exists('webmail_host')) {
if (!function_exists('mta_host')) { if (!function_exists('mta_host')) {
function mta_host(?int $domainId = null): string function mta_host(?int $domainId = null): string
{ {
// 1⃣ Vorrang: Datenbankwert (z. B. aus der domains-Tabelle)
if ($domainId) { if ($domainId) {
try { try {
$domain = \App\Models\Domain::find($domainId); $domain = \App\Models\Domain::find($domainId);
@ -39,17 +38,25 @@ if (!function_exists('mta_host')) {
return $domain->mta_host; return $domain->mta_host;
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
// DB evtl. noch nicht migriert — fallback auf env // DB evtl. noch nicht migriert — fallback auf env
} }
} }
// 2⃣ ENV-Variante (z. B. MTA_SUB=mail01)
$sub = env('MTA_SUB'); $sub = env('MTA_SUB');
if ($sub) { if ($sub) {
return domain_host($sub); return domain_host($sub);
} }
// 3⃣ Notfall: statischer Fallback
return domain_host('mx'); return domain_host('mx');
} }
} }
if (! function_exists('countryFlag')) {
function countryFlag(string $code): string
{
$code = strtoupper($code);
return implode('', array_map(
fn($char) => mb_chr(ord($char) + 127397, 'UTF-8'),
str_split($code)
));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -92,7 +92,7 @@ class MailboxCreateModal extends ModalComponent
{ {
// alle Nicht-System-Domains in Select // alle Nicht-System-Domains in Select
$this->domains = Domain::query() $this->domains = Domain::query()
->where('is_system', false) ->where('is_system', false)->where('is_server', false)
->orderBy('domain')->get(['id', 'domain'])->toArray(); ->orderBy('domain')->get(['id', 'domain'])->toArray();
// vorselektieren falls mitgegeben, sonst 1. Domain (falls vorhanden) // vorselektieren falls mitgegeben, sonst 1. Domain (falls vorhanden)
@ -291,251 +291,3 @@ class MailboxCreateModal extends ModalComponent
return view('livewire.ui.mail.modal.mailbox-create-modal'); return view('livewire.ui.mail.modal.mailbox-create-modal');
} }
} }
//namespace App\Livewire\Ui\Mail\Modal;
//
//use App\Models\Domain;
//use App\Models\MailUser;
//use Illuminate\Database\QueryException;
//use Illuminate\Support\Facades\Hash;
//use Illuminate\Validation\Rule;
//use Livewire\Attributes\On;
//use LivewireUI\Modal\ModalComponent;
//
//class MailboxCreateModal extends ModalComponent
//{
// // optional vorselektierte Domain
// public ?int $domain_id = null;
//
// // Anzeige
// public string $domain_name = '';
// /** @var array<int,array{id:int,domain:string}> */
// public array $domains = [];
// public string $email_preview = '';
//
// public string $localpart = '';
// public ?string $display_name = null;
// public ?string $password = null;
// public int $quota_mb = 0;
// public ?int $rate_limit_per_hour = null;
// public bool $is_active = true;
// public bool $must_change_pw = true;
//
// // Limits / Status
// public ?int $limit_max_mailboxes = null;
// public ?int $limit_default_quota_mb = null;
// public ?int $limit_max_quota_per_mb = null;
// public ?int $limit_total_quota_mb = null; // 0 = unlimitiert
// public ?int $limit_domain_rate_per_hour = null;
// public bool $allow_rate_limit_override = false;
//
// public int $mailbox_count_used = 0;
// public int $domain_storage_used_mb = 0;
//
// // Hints/Flags
// public string $quota_hint = '';
// public bool $rate_limit_readonly = false;
// public bool $no_mailbox_slots = false;
// public bool $no_storage_left = false;
// public bool $can_create = true;
// public string $block_reason = '';
//
// /* ---------- Validation ---------- */
// protected function rules(): array
// {
// $maxPerMailbox = $this->limit_max_quota_per_mb ?? PHP_INT_MAX;
// $remainingByTotal = (is_null($this->limit_total_quota_mb) || (int)$this->limit_total_quota_mb === 0)
// ? PHP_INT_MAX
// : max(0, (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb);
// $cap = min($maxPerMailbox, $remainingByTotal);
//
// return [
// 'domain_id' => ['required', Rule::exists('domains', 'id')],
// 'localpart' => [
// 'required', 'max:191', 'regex:/^[A-Za-z0-9._%+-]+$/',
// Rule::unique('mail_users', 'localpart')->where(fn($q) => $q->where('domain_id', $this->domain_id)),
// ],
// 'display_name' => ['nullable', 'max:191'],
// 'password' => ['nullable', 'min:8'],
// 'quota_mb' => ['required', 'integer', 'min:0', 'max:' . $cap],
// 'rate_limit_per_hour' => ['nullable', 'integer', 'min:1'],
// 'is_active' => ['boolean'],
// 'must_change_pw' => ['boolean'],
// ];
// }
//
// /* ---------- Lifecycle ---------- */
// public function mount(?int $domainId = null): void
// {
// // alle Nicht-System-Domains in Select
// $this->domains = Domain::query()
// ->where('is_system', false)
// ->orderBy('domain')->get(['id', 'domain'])->toArray();
//
// // vorselektieren falls mitgegeben, sonst 1. Domain (falls vorhanden)
// $this->domain_id = $domainId ?: ($this->domains[0]['id'] ?? null);
//
// // Limits + Anzeige laden
// $this->syncDomainContext();
// }
//
// public function updatedDomainId(): void
// {
// $this->resetErrorBag(); // scoped unique etc.
// $this->syncDomainContext();
// }
//
// public function updatedLocalpart(): void
// {
// $this->localpart = strtolower(trim($this->localpart));
// $this->rebuildEmailPreview();
// }
//
// public function updatedQuotaMb(): void
// {
// $this->recomputeQuotaHints();
// $this->recomputeBlockers();
// }
//
// /* ---------- Helpers ---------- */
// private function syncDomainContext(): void
// {
// if (!$this->domain_id) return;
//
// $d = Domain::query()
// ->withCount('mailUsers')
// ->withSum('mailUsers as used_storage_mb', 'quota_mb')
// ->findOrFail($this->domain_id);
//
// $this->domain_name = $d->domain;
// $this->limit_max_mailboxes = (int)$d->max_mailboxes;
// $this->limit_default_quota_mb = (int)$d->default_quota_mb;
// $this->limit_max_quota_per_mb = $d->max_quota_per_mailbox_mb !== null ? (int)$d->max_quota_per_mailbox_mb : null;
// $this->limit_total_quota_mb = (int)$d->total_quota_mb; // 0 = unlimitiert
// $this->limit_domain_rate_per_hour = $d->rate_limit_per_hour !== null ? (int)$d->rate_limit_per_hour : null;
// $this->allow_rate_limit_override = (bool)$d->rate_limit_override;
//
// $this->mailbox_count_used = (int)$d->mail_users_count;
// $this->domain_storage_used_mb = (int)($d->used_storage_mb ?? 0);
//
// // Defaults
// $this->quota_mb = $this->limit_default_quota_mb ?? 0;
// if (!$this->allow_rate_limit_override) {
// $this->rate_limit_per_hour = $this->limit_domain_rate_per_hour;
// $this->rate_limit_readonly = true;
// } else {
// $this->rate_limit_per_hour = $this->limit_domain_rate_per_hour;
// $this->rate_limit_readonly = false;
// }
//
// $this->rebuildEmailPreview();
// $this->recomputeQuotaHints();
// $this->recomputeBlockers();
// }
//
// private function rebuildEmailPreview(): void
// {
// $this->email_preview = $this->localpart && $this->domain_name
// ? ($this->localpart . '@' . $this->domain_name) : '';
// }
//
// private function recomputeQuotaHints(): void
// {
// $parts = [];
//
// if (!is_null($this->limit_total_quota_mb) && (int)$this->limit_total_quota_mb > 0) {
// $remainingNow = max(0, (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb);
// $remainingAfter = max(0, $remainingNow - max(0, (int)$this->quota_mb));
// $parts[] = "Verbleibend jetzt: {$remainingNow} MiB";
// $parts[] = "nach Speichern: {$remainingAfter} MiB";
// }
// if (!is_null($this->limit_max_quota_per_mb)) $parts[] = "Max {$this->limit_max_quota_per_mb} MiB pro Postfach";
// if (!is_null($this->limit_default_quota_mb)) $parts[] = "Standard: {$this->limit_default_quota_mb} MiB";
//
// $this->quota_hint = implode(' · ', $parts);
// }
//
// private function recomputeBlockers(): void
// {
// // Slots
// $this->no_mailbox_slots = false;
// if (!is_null($this->limit_max_mailboxes)) {
// $free = (int)$this->limit_max_mailboxes - (int)$this->mailbox_count_used;
// if ($free <= 0) $this->no_mailbox_slots = true;
// }
//
// // Speicher
// $this->no_storage_left = false;
// if (!is_null($this->limit_total_quota_mb) && (int)$this->limit_total_quota_mb > 0) {
// $remaining = (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb;
// if ($remaining <= 0) $this->no_storage_left = true;
// }
//
// $reasons = [];
// if ($this->no_mailbox_slots) $reasons[] = 'Keine freien Postfach-Slots in dieser Domain.';
// if ($this->no_storage_left) $reasons[] = 'Kein Domain-Speicher mehr verfügbar.';
// $this->block_reason = implode(' ', $reasons);
// $this->can_create = !($this->no_mailbox_slots || $this->no_storage_left);
// }
//
// /* ---------- Save ---------- */
// #[On('mailbox:create')]
// public function save(): void
// {
// $this->recomputeBlockers();
// if (!$this->can_create) {
// $this->addError('domain_id', $this->block_reason ?: 'Erstellung aktuell nicht möglich.');
// return;
// }
//
// $data = $this->validate();
// $email = $data['localpart'] . '@' . $this->domain_name;
//
// try {
// $u = new MailUser();
// $u->domain_id = $data['domain_id'];
// $u->localpart = $data['localpart'];
// $u->email = $email;
// $u->display_name = $this->display_name ?: null;
// $u->password_hash = $this->password ? Hash::make($this->password) : null;
// $u->is_system = false;
// $u->is_active = (bool)$data['is_active'];
// $u->must_change_pw = (bool)$data['must_change_pw'];
// $u->quota_mb = (int)$data['quota_mb'];
// $u->rate_limit_per_hour = $data['rate_limit_per_hour'];
// $u->save();
// } catch (QueryException $e) {
// $msg = strtolower($e->getMessage());
// if (str_contains($msg, 'mail_users_domain_localpart_unique')) {
// $this->addError('localpart', 'Dieses Postfach existiert in dieser Domain bereits.');
// return;
// }
// if (str_contains($msg, 'mail_users_email_unique')) {
// $this->addError('localpart', 'Diese E-Mail-Adresse ist bereits vergeben.');
// return;
// }
// throw $e;
// }
//
// $this->dispatch('mailbox:created');
// $this->dispatch('closeModal');
// $this->dispatch('toast',
// type: 'done',
// badge: 'Postfach',
// title: 'Postfach angelegt',
// text: 'Das Postfach <b>' . e($email) . '</b> wurde erfolgreich angelegt.',
// duration: 6000
// );
//
// }
//
// public static function modalMaxWidth(): string
// {
// return '3xl';
// }
//
// public function render()
// {
// return view('livewire.ui.mail.modal.mailbox-create-modal');
// }
//}

View File

@ -0,0 +1,200 @@
<?php
namespace App\Livewire\Ui\Security;
use Livewire\Attributes\On;
use Livewire\Component;
class Fail2banBanlist extends Component
{
/**
* null oder '*' => alle Jails
* 'recidive' => nur dieses Jail
* 'mailwolt-blacklist' etc.
*/
public ?string $jail = null;
/**
* Struktur für Blade (reine Ausgabe, keine Logik im Blade):
* [
* [
* 'ip' => '1.2.3.4',
* 'jail' => 'recidive',
* 'permanent' => false,
* 'label' => 'Temporär', // oder 'Permanent'
* 'box' => 'border-amber-400/20 bg-white/3', // Kartenstil
* 'badge' => 'border-amber-400/30 bg-amber-500/10 text-amber-200',
* 'btn' => 'border-rose-400/30 bg-rose-500/10 text-rose-200 hover:border-rose-400/50',
* ],
* ...
* ]
*
* @var array<int,array{
* ip:string,jail:string,permanent:bool,label:string,box:string,badge:string,btn:string
* }>
*/
public array $rows = [];
#[On('f2b:refresh')]
public function refreshList(): void
{
$this->loadBanned();
}
public function mount(?string $jail = null): void
{
$this->jail = $jail;
$this->loadBanned();
}
public function render()
{
return view('livewire.ui.security.fail2ban-banlist');
}
/* ================= core ================= */
private function loadBanned(): void
{
$jails = $this->jailList();
// ggf. nur ein bestimmtes Jail
if (is_string($this->jail) && $this->jail !== '' && $this->jail !== '*') {
$jails = in_array($this->jail, $jails, true) ? [$this->jail] : [];
}
$rows = [];
foreach ($jails as $j) {
$out = $this->f2b("status " . escapeshellarg($j));
if (!preg_match('/IP list:\s*(.+)$/mi', $out, $m)) {
continue;
}
$ips = preg_split('/\s+/', trim($m[1])) ?: [];
foreach ($ips as $ip) {
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
continue;
}
$permanent = $this->isPermanent($j, $ip);
if ($permanent) {
$box = 'border-rose-400/30 bg-rose-500/5';
$badge = 'border-rose-400/30 bg-rose-500/10 text-rose-200';
$label = 'Permanent';
$style = 'permanent';
$dot = 'bg-rose-500';
} else {
$box = 'border-amber-400/20 bg-white/3';
$badge = 'border-amber-400/30 bg-amber-500/10 text-amber-200';
$label = 'Temporär';
$style = 'temporary';
$dot = 'bg-amber-400';
}
$rows[] = [
'ip' => $ip,
'jail' => $j,
'permanent' => $permanent,
'style' => $style,
'label' => $label,
'box' => $box,
'badge' => $badge,
'dot' => $dot,
'btn' => 'border-rose-400/30 bg-rose-500/10 text-rose-200 hover:border-rose-400/50',
];
}
}
// Sortierung: permanent oben, dann nach Jail, dann IP
usort($rows, function ($a, $b) {
if ($a['permanent'] !== $b['permanent']) return $a['permanent'] ? -1 : 1;
if ($a['jail'] !== $b['jail']) return strcmp($a['jail'], $b['jail']);
return strcmp($a['ip'], $b['ip']);
});
$this->rows = $rows;
}
/** Entbannt eine IP **im angegebenen Jail** (Button gibt Jail mit) */
public function unban(string $ip, string $jail): void
{
if (!filter_var($ip, FILTER_VALIDATE_IP)) return;
$cmd = sprintf(
'sudo -n /usr/bin/fail2ban-client set %s unbanip %s 2>&1',
escapeshellarg($jail),
escapeshellarg($ip)
);
@shell_exec($cmd);
$this->loadBanned();
$this->dispatch('toast',
type: 'done',
badge: 'Fail2Ban',
title: 'IP entbannt',
text: "IP {$ip} in Jail „{$jail}“ entbannt.",
duration: 5000,
);
}
/* ================= helpers ================= */
/** Prüft via SQLite, ob der **letzte** Ban für (jail, ip) permanent ist (bantime < 0). */
private function isPermanent(string $jail, string $ip): bool
{
$db = $this->getDbFile();
if ($db === '' || !is_readable($db)) {
// Fallback: Blacklist-Jail ist per Design permanent
return $jail === 'mailwolt-blacklist';
}
$q = <<<SQL
WITH last AS (
SELECT MAX(timeofban) AS t
FROM bans
WHERE jail = '$jail' AND ip = '$ip'
)
SELECT bantime
FROM bans, last
WHERE jail = '$jail' AND ip = '$ip' AND timeofban = last.t
LIMIT 1;
SQL;
$cmd = sprintf(
'sudo -n /usr/bin/sqlite3 -readonly %s %s 2>&1',
escapeshellarg($db),
escapeshellarg($q)
);
$out = trim((string)@shell_exec($cmd));
if ($out === '') return ($jail === 'mailwolt-blacklist'); // Fallback
return ((int)$out) < 0;
}
/** Liste aller Jails */
private function jailList(): array
{
$out = $this->f2b('status');
if (preg_match('/Jail list:\s*(.+)$/mi', $out, $m)) {
$jails = array_map('trim', preg_split('/\s*,\s*/', trim($m[1])));
return array_values(array_filter($jails, fn($v) => $v !== ''));
}
return [];
}
/** fail2ban-client über sudo aufrufen */
private function f2b(string $args): string
{
return (string) @shell_exec('sudo -n /usr/bin/fail2ban-client '.$args.' 2>&1');
}
/** Pfad zur Fail2Ban-SQLite-DB holen */
private function getDbFile(): string
{
$out = $this->f2b('get dbfile');
$lines = array_values(array_filter(array_map('trim', preg_split('/\r?\n/', $out))));
$path = end($lines) ?: '';
$path = preg_replace('/^`?-?\s*/', '', $path);
return $path ?: '/var/lib/fail2ban/fail2ban.sqlite3';
}
}

View File

@ -0,0 +1,543 @@
<?php
namespace App\Livewire\Ui\Security;
use Livewire\Attributes\On;
use Livewire\Component;
use App\Models\Fail2banSetting;
use App\Models\Fail2banIpList;
use Illuminate\Validation\ValidationException;
class Fail2banSettings extends Component
{
// Formfelder
public int $bantime;
public int $max_bantime;
public bool $bantime_increment;
public float $bantime_factor;
public int $max_retry;
public int $findtime;
public int $cidr_v4;
public int $cidr_v6;
public bool $external_mode;
public array $whitelist = [];
public array $blacklist = [];
public Fail2banSetting $settings;
#[On('f2b:refresh')]
public function refreshLists(): void
{
$this->whitelist = Fail2banIpList::visibleWhitelist()->pluck('ip')->toArray();
$this->blacklist = Fail2banIpList::visibleBlacklist()->pluck('ip')->toArray();
}
public function mount(): void
{
$this->settings = Fail2banSetting::first() ?? Fail2banSetting::create([
'bantime' => 3600,
'max_bantime' => 43200,
'bantime_increment' => true,
'bantime_factor' => 1.5,
'max_retry' => 3,
'findtime' => 600,
'cidr_v4' => 32,
'cidr_v6' => 128,
'external_mode' => false,
]);
$this->fill([
'bantime' => (int)$this->settings->bantime,
'max_bantime' => (int)$this->settings->max_bantime,
'bantime_increment' => (bool)$this->settings->bantime_increment,
'bantime_factor' => (float)$this->settings->bantime_factor,
'max_retry' => (int)$this->settings->max_retry,
'findtime' => (int)$this->settings->findtime,
'cidr_v4' => (int)$this->settings->cidr_v4,
'cidr_v6' => (int)$this->settings->cidr_v6,
'external_mode' => (bool)$this->settings->external_mode,
]);
$this->refreshLists();
}
public function save(): void
{
$this->validate([
'bantime' => 'required|integer|min:60',
'max_bantime' => 'required|integer|min:60',
'bantime_factor' => 'required|numeric|min:1',
'max_retry' => 'required|integer|min:1',
'findtime' => 'required|integer|min:60',
'cidr_v4' => 'required|integer|min:8|max:32',
'cidr_v6' => 'required|integer|min:8|max:128',
]);
try {
// Einstellungen speichern
$this->settings->update([
'bantime' => $this->bantime,
'max_bantime' => $this->max_bantime,
'bantime_increment' => $this->bantime_increment,
'bantime_factor' => $this->bantime_factor,
'max_retry' => $this->max_retry,
'findtime' => $this->findtime,
'cidr_v4' => $this->cidr_v4,
'cidr_v6' => $this->cidr_v6,
'external_mode' => $this->external_mode,
]);
// Config-Dateien schreiben
$this->writeDefaultsConfig();
$this->writeWhitelistConfig();
// Fail2Ban reload
$this->runCommand('sudo -n /usr/bin/fail2ban-client reload');
$this->dispatch('toast',
type: 'success',
badge: 'Fail2Ban',
title: 'Einstellungen gespeichert',
text: 'Die Fail2Ban-Konfiguration wurde erfolgreich übernommen und ist jetzt aktiv.',
duration: 6000,
);
} catch (\Throwable $e) {
$this->dispatch('toast',
type: 'error',
badge: 'Fail2Ban',
title: 'Fehler beim Anwenden',
text: 'Die neuen Einstellungen konnten nicht angewendet werden: ' . $e->getMessage(),
duration: 8000,
);
}
}
/* ---------------- Config-Dateien ---------------- */
protected function writeDefaultsConfig(): void
{
$s = $this->settings;
$content = <<<CONF
[DEFAULT]
bantime = {$s->bantime}
findtime = {$s->findtime}
maxretry = {$s->max_retry}
bantime.increment = {$this->boolToStr($s->bantime_increment)}
bantime.factor = {$s->bantime_factor}
bantime.maxtime = {$s->max_bantime}
CONF;
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/00-mailwolt-defaults.local', $content);
}
protected function writeWhitelistConfig(): void
{
// zieht System + User-Whitelist
$ips = Fail2banIpList::allWhitelistForConfig();
$ignore = implode(' ', array_unique(array_filter($ips)));
$content = "[DEFAULT]\nignoreip = {$ignore}\n";
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
}
/* ---------------- Helper ---------------- */
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]);
stream_get_contents($pipes[1]);
stream_get_contents($pipes[2]);
$code = proc_close($proc);
if ($code !== 0) {
throw new \RuntimeException("tee failed writing to {$target}");
}
}
private function runCommand(string $cmd): void
{
$output = [];
$return = 0;
exec($cmd . ' 2>&1', $output, $return);
if ($return !== 0) {
throw new \RuntimeException("Command failed ($return): {$cmd}\n" . implode("\n", $output));
}
}
private function boolToStr(bool $v): string
{
return $v ? 'true' : 'false';
}
public function render()
{
return view('livewire.ui.security.fail2ban-settings');
}
}
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Attributes\On;
//use Livewire\Component;
//use App\Models\Fail2banSetting;
//use App\Models\Fail2banIpList;
//
//class Fail2banSettings extends Component
//{
// // Formfelder
// public int $bantime;
// public int $max_bantime;
// public bool $bantime_increment;
// public float $bantime_factor;
// public int $max_retry;
// public int $findtime;
// public int $cidr_v4;
// public int $cidr_v6;
// public bool $external_mode;
//
// public array $whitelist = [];
// public array $blacklist = [];
//
// public Fail2banSetting $settings;
//
// #[On('f2b:refresh')]
// public function refreshLists(): void
// {
// $this->whitelist = Fail2banIpList::visibleWhitelist()->pluck('ip')->toArray();
// $this->blacklist = Fail2banIpList::visibleBlacklist()->pluck('ip')->toArray();
// }
//
// public function mount(): void
// {
// // Setting holen oder Defaults anlegen
// $this->settings = Fail2banSetting::first() ?? Fail2banSetting::create([
// 'bantime' => 3600,
// 'max_bantime' => 43200,
// 'bantime_increment' => true,
// 'bantime_factor' => 1.5,
// 'max_retry' => 3,
// 'findtime' => 600,
// 'cidr_v4' => 32,
// 'cidr_v6' => 128,
// 'external_mode' => false,
// ]);
//
// // Properties befüllen
// $this->fill([
// 'bantime' => (int)$this->settings->bantime,
// 'max_bantime' => (int)$this->settings->max_bantime,
// 'bantime_increment' => (bool)$this->settings->bantime_increment,
// 'bantime_factor' => (float)$this->settings->bantime_factor,
// 'max_retry' => (int)$this->settings->max_retry,
// 'findtime' => (int)$this->settings->findtime,
// 'cidr_v4' => (int)$this->settings->cidr_v4,
// 'cidr_v6' => (int)$this->settings->cidr_v6,
// 'external_mode' => (bool)$this->settings->external_mode,
// ]);
//
// $this->refreshLists();
// }
//
// public function save(): void
// {
// $this->validate([
// 'bantime' => 'required|integer|min:60',
// 'max_bantime' => 'required|integer|min:60',
// 'bantime_factor' => 'required|numeric|min:1',
// 'max_retry' => 'required|integer|min:1',
// 'findtime' => 'required|integer|min:60',
// 'cidr_v4' => 'required|integer|min:8|max:32',
// 'cidr_v6' => 'required|integer|min:8|max:128',
// ]);
//
// // Einstellungen speichern
// $this->settings->update([
// 'bantime' => $this->bantime,
// 'max_bantime' => $this->max_bantime,
// 'bantime_increment' => $this->bantime_increment,
// 'bantime_factor' => $this->bantime_factor,
// 'max_retry' => $this->max_retry,
// 'findtime' => $this->findtime,
// 'cidr_v4' => $this->cidr_v4,
// 'cidr_v6' => $this->cidr_v6,
// 'external_mode' => $this->external_mode,
// ]);
//
// // Config-Dateien schreiben
// $this->writeDefaultsConfig();
// $this->writeWhitelistConfig();
//
// // Fail2Ban reload
// $this->runCommand('sudo -n /usr/bin/fail2ban-client reload');
//
// $this->dispatch('toast',
// type: 'done',
// badge: 'Fail2Ban',
// title: 'Einstellungen gespeichert',
// text: 'Die Fail2Ban-Konfiguration wurde erfolgreich übernommen und ist jetzt aktiv.',
// duration: 6000,
// );
// }
//
// protected function writeDefaultsConfig(): void
// {
// $s = $this->settings;
//
// $content = <<<CONF
//[DEFAULT]
//bantime = {$s->bantime}
//findtime = {$s->findtime}
//maxretry = {$s->max_retry}
//bantime.increment = {$this->boolToStr($s->bantime_increment)}
//bantime.factor = {$s->bantime_factor}
//bantime.maxtime = {$s->max_bantime}
//CONF;
//
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/00-mailwolt-defaults.local', $content);
// }
//
// protected function writeWhitelistConfig(): void
// {
// $ips = Fail2banIpList::where('type', 'whitelist')->pluck('ip')->toArray();
// $ignore = implode(' ', array_unique(array_filter($ips)));
//
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
//
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
// }
//
// /**
// * Schreibt Root-Dateien sicher via `sudo tee`
// */
// 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));
//
// $descriptorspec = [
// 0 => ['pipe', 'r'],
// 1 => ['pipe', 'w'],
// 2 => ['pipe', 'w'],
// ];
//
// $proc = proc_open($cmd, $descriptorspec, $pipes, null, null);
// if (!is_resource($proc)) {
// throw new \RuntimeException('Failed to start tee');
// }
//
// fwrite($pipes[0], $content);
// fclose($pipes[0]);
// stream_get_contents($pipes[1]);
// stream_get_contents($pipes[2]);
// $exitCode = proc_close($proc);
//
// if ($exitCode !== 0) {
// throw new \RuntimeException("tee failed writing to {$target}");
// }
// }
//
// /**
// * Führt Systembefehle aus und wirft Exception bei Fehlern
// */
// private function runCommand(string $cmd): void
// {
// $output = [];
// $return = 0;
// exec($cmd . ' 2>&1', $output, $return);
//
// if ($return !== 0) {
// throw new \RuntimeException("Command failed ($return): {$cmd}\n" . implode("\n", $output));
// }
// }
//
// private function boolToStr(bool $v): string
// {
// return $v ? 'true' : 'false';
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2ban-settings');
// }
//}
//
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Attributes\On;
//use Livewire\Component;
//use App\Models\Fail2banSetting;
//use App\Models\Fail2banIpList;
//
//class Fail2banSettings extends Component
//{
// // Formfelder
// public int $bantime;
// public int $max_bantime;
// public bool $bantime_increment;
// public float $bantime_factor;
// public int $max_retry;
// public int $findtime;
// public int $cidr_v4;
// public int $cidr_v6;
// public bool $external_mode;
//
// public array $whitelist = [];
// public array $blacklist = [];
//
// public Fail2banSetting $settings;
//
// #[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();
// }
//
// public function mount(): void
// {
// // Setting holen oder mit Defaults anlegen
// $this->settings = Fail2banSetting::first() ?? Fail2banSetting::create([
// 'bantime' => 3600, 'max_bantime' => 43200, 'bantime_increment' => true,
// 'bantime_factor' => 1.5, 'max_retry' => 3, 'findtime' => 600,
// 'cidr_v4' => 32, 'cidr_v6' => 128, 'external_mode' => false,
// ]);
//
// // Properties füllen (KEINE Mixed-Objekte in Inputs binden)
// $this->fill([
// 'bantime' => (int)$this->settings->bantime,
// 'max_bantime' => (int)$this->settings->max_bantime,
// 'bantime_increment' => (bool)$this->settings->bantime_increment,
// 'bantime_factor' => (float)$this->settings->bantime_factor,
// 'max_retry' => (int)$this->settings->max_retry,
// 'findtime' => (int)$this->settings->findtime,
// 'cidr_v4' => (int)$this->settings->cidr_v4,
// 'cidr_v6' => (int)$this->settings->cidr_v6,
// 'external_mode' => (bool)$this->settings->external_mode,
// ]);
//
// $this->whitelist = Fail2banIpList::where('type','whitelist')->pluck('ip')->toArray();
// $this->blacklist = Fail2banIpList::where('type','blacklist')->pluck('ip')->toArray();
// }
//
// public function save(): void
// {
// $this->validate([
// 'bantime' => 'required|integer|min:60',
// 'max_bantime' => 'required|integer|min:60',
// 'bantime_factor' => 'required|numeric|min:1',
// 'max_retry' => 'required|integer|min:1',
// 'findtime' => 'required|integer|min:60',
// 'cidr_v4' => 'required|integer|min:8|max:32',
// 'cidr_v6' => 'required|integer|min:8|max:128',
// ]);
//
// $this->settings->update([
// 'bantime' => $this->bantime,
// 'max_bantime' => $this->max_bantime,
// 'bantime_increment' => $this->bantime_increment,
// 'bantime_factor' => $this->bantime_factor,
// 'max_retry' => $this->max_retry,
// 'findtime' => $this->findtime,
// 'cidr_v4' => $this->cidr_v4,
// 'cidr_v6' => $this->cidr_v6,
// 'external_mode' => $this->external_mode,
// ]);
//
// $this->writeDefaultsConfig();
// $this->writeWhitelistConfig();
//
// @shell_exec('sudo fail2ban-client reload');
// $this->dispatch('notify', message: 'Gespeichert & Fail2Ban neu geladen.');
// }
//
// protected function writeDefaultsConfig(): void
// {
// $s = $this->settings;
// $content = <<<CONF
//[DEFAULT]
//bantime = {$s->bantime}
//findtime = {$s->findtime}
//maxretry = {$s->max_retry}
//bantime.increment = {$this->boolToStr($s->bantime_increment)}
//bantime.factor = {$s->bantime_factor}
//bantime.maxtime = {$s->max_bantime}
//CONF;
// file_put_contents('/etc/fail2ban/jail.d/00-mailwolt-defaults.local', $content);
// }
//
// protected function writeWhitelistConfig(): void
// {
// $ips = Fail2banIpList::where('type','whitelist')->pluck('ip')->toArray();
// $ignore = implode(' ', array_unique(array_filter($ips)));
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
// file_put_contents('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
// }
//
// private function writeRootFileViaTee(string $target, string $content): void
// {
// // Nur erlaubte Pfade (Hardening)
// 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));
//
// $descriptorspec = [
// 0 => ['pipe', 'r'], // stdin -> tee
// 1 => ['pipe', 'w'], // stdout
// 2 => ['pipe', 'w'], // stderr
// ];
//
// $proc = proc_open($cmd, $descriptorspec, $pipes, null, null);
// if (!is_resource($proc)) {
// throw new \RuntimeException('Failed to start tee');
// }
//
// 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 boolToStr(bool $v): string
// {
// return $v ? 'true' : 'false';
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2ban-settings');
// }
//}

View File

@ -74,50 +74,102 @@ class Fail2BanJailModal extends ModalComponent
// //
// $this->rows = $rows; // $this->rows = $rows;
// logger()->debug('rows='.json_encode($rows, JSON_UNESCAPED_SLASHES)); // logger()->debug('rows='.json_encode($rows, JSON_UNESCAPED_SLASHES));
// }
// protected function load(bool $force = false): void
// {
// $jail = $this->jail;
//
// // 1) Primär: DB
// $rows = $this->activeBansFromDb($jail);
//
// // 2) Fallback: status parsen, wenn DB leer (z. B. sehr alte F2B-Setups)
// if (empty($rows)) {
// [, $s] = $this->f2b('status '.escapeshellarg($jail));
// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
// $defaultBantime = $this->getBantime($jail);
//
// foreach ($ips as $ip) {
// $banAt = $this->lastBanTimestamp($jail, $ip);
// $remaining = $banAt !== null ? max(0, $defaultBantime - (time() - $banAt)) : -2;
// $until = ($remaining > 0 && $banAt) ? $banAt + $defaultBantime : null;
// $rows[] = [
// 'ip' => $ip,
// 'bantime' => $defaultBantime,
// 'banned_at' => $banAt,
// 'remaining' => $remaining,
// 'until' => $until,
// ];
// }
// }
//
// // Präsentationswerte bauen (einheitlich)
// $presented = [];
// foreach ($rows as $r) {
// [$timeText, $metaText, $boxClass] = $this->present($r['remaining'], $r['banned_at'], $r['until']);
// $presented[] = $r + [
// 'time_text' => $timeText,
// 'meta_text' => $metaText,
// 'box_class' => $boxClass,
// ];
// }
//
// $this->rows = $presented;
// logger()->debug('rows='.json_encode($presented, JSON_UNESCAPED_SLASHES));
// } // }
protected function load(bool $force = false): void protected function load(bool $force = false): void
{ {
$jail = $this->jail; $jail = $this->jail;
// 1) Primär: DB // 1) IP-Liste IMMER aus "status <jail>" holen (Quelle der Wahrheit)
$rows = $this->activeBansFromDb($jail); [, $s] = $this->f2b('status '.escapeshellarg($jail));
$ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
$ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
// 2) Fallback: status parsen, wenn DB leer (z. B. sehr alte F2B-Setups) $defaultBantime = $this->getBantime($jail);
if (empty($rows)) {
[, $s] = $this->f2b('status '.escapeshellarg($jail));
$ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
$ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
$defaultBantime = $this->getBantime($jail);
foreach ($ips as $ip) { $rows = [];
$banAt = $this->lastBanTimestamp($jail, $ip); foreach ($ips as $ip) {
$remaining = $banAt !== null ? max(0, $defaultBantime - (time() - $banAt)) : -2; $banAt = null; $until = null; $remaining = null;
$until = ($remaining > 0 && $banAt) ? $banAt + $defaultBantime : null;
$rows[] = [ // 1) Primär: DB
'ip' => $ip, if ($info = $this->banInfoFromDb($jail, $ip)) {
'bantime' => $defaultBantime, $banAt = $info['banned_at'];
'banned_at' => $banAt, if ((int)$info['expire'] === -1) {
'remaining' => $remaining, $remaining = -1; // permanent
'until' => $until, } elseif ((int)$info['expire'] > 0) {
]; $until = (int)$info['expire'];
$remaining = max(0, $until - time());
} else {
// kein expire in DB → Fallback unten
}
} }
// 2) Fallback: KEIN fixes Datum, nur "~ Bantime"
if ($remaining === null) {
$remaining = -2; // "~ ca. {$defaultBantime}s"
}
[$timeText, $metaText, $boxClass] = $this->present($remaining, $banAt, $until);
$rows[] = [
'ip' => $ip,
'bantime' => $defaultBantime,
'banned_at' => $banAt,
'remaining' => $remaining,
'until' => $until,
'time_text' => $timeText,
'meta_text' => $metaText,
'box_class' => $boxClass,
];
} }
// Präsentationswerte bauen (einheitlich) $this->rows = $rows;
$presented = []; logger()->debug('rows='.json_encode($rows, JSON_UNESCAPED_SLASHES));
foreach ($rows as $r) {
[$timeText, $metaText, $boxClass] = $this->present($r['remaining'], $r['banned_at'], $r['until']);
$presented[] = $r + [
'time_text' => $timeText,
'meta_text' => $metaText,
'box_class' => $boxClass,
];
}
$this->rows = $presented;
logger()->debug('rows='.json_encode($presented, JSON_UNESCAPED_SLASHES));
} }
/** ROBUST: findet Binaries automatisch */ /** ROBUST: findet Binaries automatisch */
private function bin(string $name): string private function bin(string $name): string
{ {
@ -136,7 +188,7 @@ class Fail2BanJailModal extends ModalComponent
WITH last AS ( WITH last AS (
SELECT ip, MAX(timeofban) AS t SELECT ip, MAX(timeofban) AS t
FROM bans FROM bans
WHERE jail=%s WHERE jail=:JAIL:
GROUP BY ip GROUP BY ip
), ),
curr AS ( curr AS (
@ -160,8 +212,8 @@ WHERE bantime < 0
ORDER BY timeofban DESC; ORDER BY timeofban DESC;
SQL; SQL;
$q = sprintf($sql, $this->sql($jail)); $qSql = str_replace(':JAIL:', $this->sql($jail), $sql);
$cmd = "$sudo -n $sqlite -readonly ".escapeshellarg($db).' '.escapeshellarg($q).' 2>&1'; $cmd = "$sudo -n $sqlite -readonly ".escapeshellarg($db).' '.escapeshellarg($qSql).' 2>&1';
$out = trim((string)@shell_exec($cmd)); $out = trim((string)@shell_exec($cmd));
if ($out === '') return []; if ($out === '') return [];
@ -236,7 +288,11 @@ SQL;
private function getDbFile(): string private function getDbFile(): string
{ {
[, $out] = $this->f2b('get dbfile'); [, $out] = $this->f2b('get dbfile');
$path = trim($out); // nimm die letzte nicht-leere Zeile
$lines = array_values(array_filter(array_map('trim', preg_split('/\r?\n/', $out))));
$path = end($lines) ?: '';
// fail2ban zeigt manchmal "`- /pfad" → führende Zeichen abschneiden
$path = preg_replace('/^`?-?\s*/', '', $path);
return $path !== '' ? $path : '/var/lib/fail2ban/fail2ban.sqlite3'; return $path !== '' ? $path : '/var/lib/fail2ban/fail2ban.sqlite3';
} }

View File

@ -0,0 +1,567 @@
<?php
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 (!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
$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::create(['ip' => $ip, 'type' => $this->type]);
if ($this->type === 'whitelist') {
// Whitelist-Datei aktualisieren + Fail2Ban reload
$this->writeWhitelistConfig();
$this->reloadFail2ban();
// UI aktualisieren & Toast
$this->dispatch('f2b:refresh');
$this->dispatch('toast',
type: 'success',
badge: 'Fail2Ban',
title: 'Whitelist aktualisiert',
text: 'Die IP wurde erfolgreich zur Whitelist hinzugefügt und ist nun freigegeben.',
duration: 6000,
);
} else {
// Blacklist = sofort bannen
$this->banIp($ip);
// UI aktualisieren & Toast
$this->dispatch('f2b:refresh');
$this->dispatch('toast',
type: 'warning',
badge: 'Fail2Ban',
title: 'Blacklist aktualisiert',
text: 'Die IP wurde zur Blacklist hinzugefügt und umgehend blockiert.',
duration: 6000,
);
}
// Modal bewusst am Ende schließen (Toast bleibt sichtbar)
$this->closeModal();
}
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();
$this->dispatch('f2b:refresh');
$this->dispatch('toast',
type: 'info',
badge: 'Fail2Ban',
title: 'Whitelist geändert',
text: 'Die IP wurde aus der Whitelist entfernt.',
duration: 6000,
);
} else {
$this->unbanIp($ip);
$this->dispatch('f2b:refresh');
$this->dispatch('toast',
type: 'info',
badge: 'Fail2Ban',
title: 'Blacklist geändert',
text: 'Die IP wurde aus der Blacklist entfernt und ist wieder freigegeben.',
duration: 6000,
);
}
$this->closeModal();
}
/* ---------------- 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 writeWhitelistConfig(): void
{
// WICHTIG: inkl. System-IPs (unsichtbar in der UI)
$ips = Fail2banIpList::allWhitelistForConfig();
$ignore = implode(' ', array_unique(array_filter($ips)));
$content = "[DEFAULT]\nignoreip = {$ignore}\n";
$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 -n /usr/bin/fail2ban-client reload 2>&1');
}
private function banIp(string $ip): void
{
$ipEsc = escapeshellarg($ip);
@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 -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 (!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::create(['ip' => $ip, 'type' => $this->type]);
//
// if ($this->type === 'whitelist') {
// $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->closeModal();
// $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 {
// $this->unbanIp($ip);
// }
//
// $this->closeModal();
// $this->dispatch('f2b:refresh');
// $this->dispatch('toast',
// type: 'done',
// badge: 'Fail2Ban',
// title: 'Einstellungen gespeichert',
// text: 'Die Fail2Ban-Konfiguration wurde erfolgreich übernommen und ist jetzt aktiv.',
// duration: 6000,
// );
// }
//
// /* ---------------- 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 writeWhitelistConfig(): void
// {
// // WICHTIG: inkl. System-IPs
// $ips = Fail2banIpList::allWhitelistForConfig();
// $ignore = implode(' ', array_unique(array_filter($ips)));
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
//
// // 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 -n /usr/bin/fail2ban-client reload 2>&1');
// }
//
// private function banIp(string $ip): void
// {
// $ipEsc = escapeshellarg($ip);
// @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 -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");
// }
//}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Ui\System\Form;
use App\Models\Setting;
use Livewire\Component;
class DomainsSslForm extends Component
{
// fix / readonly aus ENV oder config
public string $mail_domain_readonly = '';
// editierbar
public string $ui_domain = '';
public string $webmail_domain = '';
protected function rules(): array
{
return [
'ui_domain' => 'nullable|string|max:190',
'webmail_domain' => 'nullable|string|max:190',
];
}
public function mount(): void
{
$this->mail_domain_readonly = (string) config('mailwolt.domain.mail', 'mx');
$this->ui_domain = Setting::get('ui_domain', $this->ui_domain);
$this->webmail_domain = Setting::get('webmail_domain', $this->webmail_domain);
}
public function save(): void
{
$this->validate();
Setting::put('ui_domain', $this->ui_domain);
Setting::put('webmail_domain', $this->webmail_domain);
$this->dispatch('toast',
type: 'done',
badge: 'System',
title: 'Domains gespeichert',
text: 'UI- und Webmail-Domain wurden übernommen.',
duration: 5000,
);
}
public function render() { return view('livewire.ui.system.form.domains-ssl-form'); }
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Livewire\Ui\System\Form;
use App\Models\Setting;
use Livewire\Component;
class GeneralForm extends Component
{
public string $locale = 'de';
public string $timezone = 'Europe/Berlin';
protected function rules(): array
{
return [
'locale' => 'required|string|max:10',
'timezone' => 'required|string|max:64',
];
}
public function mount(): void
{
// Defaults aus ENV nur für den allerersten Seed in Settings (Redis/DB)
$envLocale = env('APP_LOCALE') ?? env('APP_FALLBACK_LOCALE') ?? $this->locale;
$envTimezone = env('APP_TIMEZONE') ?? $this->timezone;
// Wenn (noch) nichts in Settings liegt, einmalig mit ENV-Werten befüllen
if (Setting::get('locale', null) === null) {
Setting::set('locale', $envLocale);
}
if (Setting::get('timezone', null) === null) {
Setting::set('timezone', $envTimezone);
}
// Ab hier ausschließlich aus Settings lesen (Redis → DB Fallback)
$this->locale = (string) Setting::get('locale', $envLocale);
$this->timezone = (string) Setting::get('timezone', $envTimezone);
// Sofort für die aktuelle Request anwenden
app()->setLocale($this->locale);
@date_default_timezone_set($this->timezone);
config([
'app.locale' => $this->locale,
'app.fallback_locale' => $this->locale,
'app.timezone' => $this->timezone,
]);
}
public function save(): void
{
$this->validate();
// Persistieren: DB → Redis (siehe Setting::set)
Setting::set('locale', $this->locale);
Setting::set('timezone', $this->timezone);
// Direkt in der laufenden Request aktivieren
app()->setLocale($this->locale);
@date_default_timezone_set($this->timezone);
config([
'app.locale' => $this->locale,
'app.fallback_locale' => $this->locale, // optional
'app.timezone' => $this->timezone,
]);
$this->dispatch('toast',
type: 'done',
badge: 'System',
title: 'Allgemein gespeichert',
text: 'Sprache und Zeitzone wurden übernommen.',
duration: 5000,
);
}
public function render()
{
return view('livewire.ui.system.form.general-form');
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Livewire\Ui\System\Form;
use App\Models\Setting;
use Livewire\Component;
class SecurityForm extends Component
{
public bool $twofa_enabled = false;
public ?int $rate_limit = 5;
public ?int $password_min = 10;
protected function rules(): array
{
return [
'twofa_enabled' => 'boolean',
'rate_limit' => 'nullable|integer|min:1|max:100',
'password_min' => 'nullable|integer|min:6|max:128',
];
}
public function mount(): void
{
$this->twofa_enabled = (bool) Setting::get('twofa_enabled', $this->twofa_enabled);
$this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit);
$this->password_min = (int) Setting::get('password_min', $this->password_min);
}
public function save(): void
{
$this->validate();
Setting::put('twofa_enabled', $this->twofa_enabled);
Setting::put('rate_limit', $this->rate_limit);
Setting::put('password_min', $this->password_min);
$this->dispatch('toast',
type: 'done',
badge: 'Sicherheit',
title: 'Sicherheit gespeichert',
text: '2FA/Rate-Limits/Passwortregeln wurden übernommen.',
duration: 5000,
);
}
public function render() { return view('livewire.ui.system.form.security-form'); }
}

View File

@ -0,0 +1,278 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Fail2banIpList extends Model
{
protected $table = 'fail2ban_ip_lists';
protected $fillable = [
'ip',
'type',
'is_system',
];
protected $casts = [
'ip' => 'string',
'type' => 'string',
'is_system' => 'boolean',
];
public const TYPE_WHITELIST = 'whitelist';
public const TYPE_BLACKLIST = 'blacklist';
/* ===========================
Boot-Hooks (Schutz & Normalisierung)
=========================== */
protected static function booted()
{
// 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.");
}
});
}
/* ===========================
Scopes
=========================== */
public function scopeWhitelist($q)
{
return $q->where('type', self::TYPE_WHITELIST);
}
public function scopeBlacklist($q)
{
return $q->where('type', self::TYPE_BLACKLIST);
}
// 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)
->where('is_system', false)
->pluck('ip')->all();
}
public static function blacklistArray(): array
{
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();
// }
//}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Models;
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',
];
protected $casts = [
'bantime' => 'integer',
'max_bantime' => 'integer',
'bantime_increment' => 'boolean',
'bantime_factor' => 'float',
'max_retry' => 'integer',
'findtime' => 'integer',
'cidr_v4' => 'integer',
'cidr_v6' => 'integer',
'external_mode' => 'boolean',
];
// /**
// * Gibt die erste Konfiguration oder Default-Werte zurück.
// */
// public static function current(): self
// {
// return static::first() ?? new static([
// 'bantime' => 3600,
// 'max_bantime' => 43200,
// 'bantime_increment' => true,
// 'bantime_factor' => 1.5,
// 'max_retry' => 5,
// 'findtime' => 600,
// 'cidr_v4' => 32,
// 'cidr_v6' => 128,
// 'external_mode' => false,
// ]);
// }
/**
* Konvertiert bool zu "true"/"false" (für Config-Dateien).
*/
public function boolToString(bool $val): string
{
return $val ? 'true' : 'false';
}
}

View File

View File

View File

View File

@ -1,6 +1,30 @@
<?php <?php
return [ return [
'domain' => [
'base' => env('BASE_DOMAIN'),
'mail' => env('MTA_SUB'),
'ui' => env('UI_SUB'),
'webmail' => env('WEBMAIL_SUB'),
],
'language' => [
'de' => [
'label' => 'Deutsch',
'locale' => 'de',
'fallback_locale' => 'de',
'flag' => 'de',
],
'en' => [
'label' => 'English',
'locale' => 'en',
'fallback_locale' => 'en',
'flag' => 'gb',
],
],
'units' => [ 'units' => [
['name' => 'nginx', 'action' => 'reload'], ['name' => 'nginx', 'action' => 'reload'],
['name' => 'postfix', 'action' => 'try-reload-or-restart'], ['name' => 'postfix', 'action' => 'try-reload-or-restart'],

View File

@ -10,8 +10,8 @@ return [
'label' => 'Mail', 'icon' => 'ph-envelope', 'items' => [ 'label' => 'Mail', 'icon' => 'ph-envelope', 'items' => [
['label' => 'Postfächer', 'route' => 'ui.mail.mailboxes.index'], ['label' => 'Postfächer', 'route' => 'ui.mail.mailboxes.index'],
['label' => 'Aliasse', 'route' => 'ui.mail.aliases.index'], ['label' => 'Aliasse', 'route' => 'ui.mail.aliases.index'],
['label' => 'Gruppen', 'route' => 'ui.mail.groups.index'], // ['label' => 'Gruppen', 'route' => 'ui.mail.groups.index'],
['label' => 'Filter', 'route' => 'ui.mail.filters.index'], // ['label' => 'Filter', 'route' => 'ui.mail.filters.index'],
['label' => 'Quarantäne', 'route' => 'ui.mail.quarantine.index'], ['label' => 'Quarantäne', 'route' => 'ui.mail.quarantine.index'],
['label' => 'Queues', 'route' => 'ui.mail.queues.index'], ['label' => 'Queues', 'route' => 'ui.mail.queues.index'],
], ],
@ -19,45 +19,59 @@ return [
[ [
'label' => 'Domains', 'icon' => 'ph-globe', 'items' => [ 'label' => 'Domains', 'icon' => 'ph-globe', 'items' => [
['label' => 'Übersicht', 'route' => 'ui.domains.index'], ['label' => 'Übersicht', 'route' => 'ui.domains.index'],
['label' => 'DNS-Assistent', 'route' => 'ui.domains.dns'], // ['label' => 'DNS-Assistent', 'route' => 'ui.domains.dns'],
['label' => 'Zertifikate', 'route' => 'ui.domains.certificates'], // ['label' => 'Zertifikate', 'route' => 'ui.domains.certificates'],
], ],
], ],
[ [
'label' => 'Webmail', 'icon' => 'ph-browser', 'items' => [ 'label' => 'Webmail', 'icon' => 'ph-browser', 'items' => [
['label' => 'Allgemein', 'route' => 'ui.webmail.settings'], ['label' => 'Allgemein', 'route' => 'ui.logout'],
['label' => 'Plugins', 'route' => 'ui.webmail.plugins'], ['label' => 'Plugins', 'route' => 'ui.logout'],
// ['label' => 'Allgemein', 'route' => 'ui.webmail.settings'],
// ['label' => 'Plugins', 'route' => 'ui.webmail.plugins'],
], ],
], ],
[ [
'label' => 'Benutzer', 'icon' => 'ph-users', 'items' => [ 'label' => 'Benutzer', 'icon' => 'ph-users', 'items' => [
['label' => 'Benutzer', 'route' => 'ui.users.index'], ['label' => 'Benutzer', 'route' => 'ui.logout'],
['label' => 'Rollen & Rechte', 'route' => 'ui.users.roles'], ['label' => 'Rollen & Rechte', 'route' => 'ui.logout'],
['label' => 'Anmeldesicherheit', 'route' => 'ui.users.security'], ['label' => 'Anmeldesicherheit', 'route' => 'ui.logout'],
// ['label' => 'Benutzer', 'route' => 'ui.users.index'],
// ['label' => 'Rollen & Rechte', 'route' => 'ui.users.roles'],
// ['label' => 'Anmeldesicherheit', 'route' => 'ui.users.security'],
], ],
], ],
[ [
'label' => 'Sicherheit', 'icon' => 'ph-shield', 'items' => [ 'label' => 'Sicherheit', 'icon' => 'ph-shield', 'items' => [
['label' => 'TLS & Ciphers', 'route' => 'ui.security.tls'], ['label' => 'TLS & Ciphers', 'route' => 'ui.security.tls'],
['label' => 'Ratelimits', 'route' => 'ui.security.abuse'], ['label' => 'Fail2Ban', 'route' => 'ui.security.fail2ban'],
['label' => 'Audit-Logs', 'route' => 'ui.security.audit'], ['label' => 'Audit-Logs', 'route' => 'ui.security.audit'],
['label' => 'Rspamd', 'route' => 'ui.security.rspamd'],
['label' => 'SSL', 'route' => 'ui.security.ssl'],
// ['label' => 'Ratelimits', 'route' => 'ui.security.audit'],
// ['label' => 'TLS & Ciphers', 'route' => 'ui.security.tls'],
// ['label' => 'Ratelimits', 'route' => 'ui.security.abuse'],
// ['label' => 'Audit-Logs', 'route' => 'ui.security.audit'],
], ],
], ],
[ [
'label' => 'System', 'icon' => 'ph-gear-six', 'items' => [ 'label' => 'System', 'icon' => 'ph-gear-six', 'items' => [
['label' => 'Einstellungen', 'route' => 'ui.system.settings'], ['label' => 'Einstellungen', 'route' => 'ui.system.settings'],
['label' => 'Dienste & Status', 'route' => 'ui.system.services'], // ['label' => 'Dienste & Status', 'route' => 'ui.system.services'],
['label' => 'Jobs & Queues', 'route' => 'ui.system.jobs'], // ['label' => 'Jobs & Queues', 'route' => 'ui.system.jobs'],
['label' => 'Logs', 'route' => 'ui.system.logs'], // ['label' => 'Logs', 'route' => 'ui.system.logs'],
['label' => 'Speicher', 'route' => 'ui.system.storage'], // ['label' => 'Speicher', 'route' => 'ui.system.storage'],
['label' => 'Über', 'route' => 'ui.system.about'], // ['label' => 'Über', 'route' => 'ui.system.about'],
], ],
], ],
[ [
'label' => 'Entwickler', 'icon' => 'ph-brackets-curly', 'items' => [ 'label' => 'Entwickler', 'icon' => 'ph-brackets-curly', 'items' => [
['label' => 'API-Schlüssel', 'route' => 'ui.dev.tokens'], ['label' => 'API-Schlüssel', 'route' => 'ui.logout'],
['label' => 'Webhooks', 'route' => 'ui.dev.webhooks'], ['label' => 'Webhooks', 'route' => 'ui.logout'],
['label' => 'Sandbox', 'route' => 'ui.dev.sandbox'], ['label' => 'Sandbox', 'route' => 'ui.logout'],
// ['label' => 'API-Schlüssel', 'route' => 'ui.dev.tokens'],
// ['label' => 'Webhooks', 'route' => 'ui.dev.webhooks'],
// ['label' => 'Sandbox', 'route' => 'ui.dev.sandbox'],
], ],
], ],
]; ];

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('fail2ban_settings', function (Blueprint $table) {
$table->id();
$table->integer('bantime')->default(3600);
$table->integer('max_bantime')->default(43200);
$table->boolean('bantime_increment')->default(true);
$table->float('bantime_factor')->default(1.5);
$table->integer('max_retry')->default(3);
$table->integer('findtime')->default(600);
$table->integer('cidr_v4')->default(32);
$table->integer('cidr_v6')->default(128);
$table->boolean('external_mode')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('fail2ban_settings');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('fail2ban_ip_lists', function (Blueprint $table) {
$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');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('fail2ban_ip_lists');
}
};

View File

@ -0,0 +1,109 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Fail2banSetting;
use App\Models\Fail2banIpList;
use Illuminate\Support\Facades\Log;
class Fail2banSeeder extends Seeder
{
public function run(): void
{
$this->command->info('⚙️ Fail2ban Defaults werden initialisiert …');
// -----------------------------------------------------------
// 1) Standardwerte für Fail2ban Settings
// -----------------------------------------------------------
$settings = Fail2banSetting::firstOrCreate([], [
'bantime' => 3600, // 1h
'max_bantime' => 43200, // 12h
'bantime_increment' => true,
'bantime_factor' => 1.5,
'max_retry' => 5,
'findtime' => 600, // 10m
'cidr_v4' => 32,
'cidr_v6' => 128,
'external_mode' => false,
]);
// -----------------------------------------------------------
// 2) Standard-IPs für Whitelist
// -----------------------------------------------------------
$defaultWhitelist = [
'127.0.0.1/8',
'::1',
];
foreach ($defaultWhitelist as $ip) {
Fail2banIpList::firstOrCreate([
'ip' => $ip,
'type' => Fail2banIpList::TYPE_WHITELIST,
]);
}
// -----------------------------------------------------------
// 3) Fail2ban Config-Dateien erzeugen
// -----------------------------------------------------------
$this->writeDefaultsConfig($settings);
$this->writeWhitelistConfig();
// -----------------------------------------------------------
// 4) Fail2ban reload (optional, falls Dienst läuft)
// -----------------------------------------------------------
$out = shell_exec('sudo -n fail2ban-client reload 2>&1') ?? '';
if (stripos($out, 'OK') === false && stripos($out, 'Reloaded') === false) {
Log::warning('Fail2ban reload output', ['out' => $out]);
$this->command->warn('⚠️ Fail2ban reload möglicherweise nicht erfolgreich.');
} else {
$this->command->info('✅ Fail2ban reload erfolgreich.');
}
}
// -----------------------------------------------------------
// interne Hilfsfunktionen
// -----------------------------------------------------------
private function writeDefaultsConfig(Fail2banSetting $s): void
{
$content = <<<CONF
[DEFAULT]
bantime = {$s->bantime}
findtime = {$s->findtime}
maxretry = {$s->max_retry}
bantime.increment = {$this->boolToString($s->bantime_increment)}
bantime.factor = {$s->bantime_factor}
bantime.maxtime = {$s->max_bantime}
CONF;
$this->atomicWrite('/etc/fail2ban/jail.d/00-mailwolt-defaults.local', $content);
}
private function writeWhitelistConfig(): void
{
$ips = Fail2banIpList::where('type', Fail2banIpList::TYPE_WHITELIST)
->pluck('ip')
->toArray();
$ignore = implode(' ', array_unique(array_filter($ips)));
$content = "[DEFAULT]\nignoreip = {$ignore}\n";
$this->atomicWrite('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
}
private function atomicWrite(string $path, string $content): void
{
$tmp = $path . '.tmp';
file_put_contents($tmp, $content);
rename($tmp, $path);
@chown($path, 'root');
@chgrp($path, 'root');
@chmod($path, 0644);
}
private function boolToString(bool $v): string
{
return $v ? 'true' : 'false';
}
}

View File

View File

View File

@ -62,8 +62,8 @@
$itemIcon = $item['icon'] ?? 'ph-dot-outline'; $itemIcon = $item['icon'] ?? 'ph-dot-outline';
@endphp @endphp
<li> <li>
{{-- <a href="{{ isset($item['route']) ? route($item['route']) : '#' }}"--}} <a href="{{ isset($item['route']) ? route($item['route']) : '#' }}"
<a href="#" {{-- <a href="#"--}}
class="sidebar-link group relative flex items-center gap-2 px-2 py-2 rounded-lg class="sidebar-link group relative flex items-center gap-2 px-2 py-2 rounded-lg
border border-transparent transition-colors border border-transparent transition-colors
hover:bg-gradient-to-t hover:from-white/10 hover:to-transparent hover:bg-gradient-to-t hover:from-white/10 hover:to-transparent

View File

@ -32,7 +32,6 @@
{{-- Row 1: Domain + Typ --}} {{-- Row 1: Domain + Typ --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{{-- DOMAIN (TailwindPlus Elements) --}} {{-- DOMAIN (TailwindPlus Elements) --}}
<div class="relative" wire:ignore id="domain-select-{{ $this->getId() }}"> <div class="relative" wire:ignore id="domain-select-{{ $this->getId() }}">
<label class="block text-xs text-white/60 mb-1">Domain</label> <label class="block text-xs text-white/60 mb-1">Domain</label>

View File

@ -0,0 +1,36 @@
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-white/90">Aktuell gebannte IPs</h3>
<button wire:click="refreshList"
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20">
<i class="ph ph-arrows-counter-clockwise text-[14px]"></i>
Aktualisieren
</button>
</div>
@if (empty($rows))
<div class="text-white/50 text-sm">Keine aktiven Banns vorhanden.</div>
@else
<div class="space-y-3">
@foreach ($rows as $r)
<div class="flex items-center justify-between rounded-2xl border px-4 py-2.5 {{ $r['box'] }}">
<div class="flex items-center gap-3">
{{-- Statuspunkt: rot=permanent, gelb=temporär --}}
<span class="inline-block w-2.5 h-2.5 rounded-full {{ $r['dot'] }}"></span>
{{-- IP klein + monospace, ohne Jail-Text --}}
<span class="font-mono text-[13px] md:text-[14px] text-white/85 tracking-normal">
{{ $r['ip'] }}
</span>
</div>
<button
wire:click="unban('{{ $r['ip'] }}','{{ $r['jail'] }}')"
class="text-[12px] px-3 py-1.5 rounded-xl border {{ $r['btn'] }}">
Entbannen
</button>
</div>
@endforeach
</div>
@endif
</div>

View File

@ -0,0 +1,141 @@
<div class="grid grid-cols-1 xl:grid-cols-3 gap-5">
{{-- LEFT 2/3 --}}
<div class="xl:col-span-2 space-y-5">
<div class="glass-card p-5">
<div class="flex items-center justify-between mb-4">
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<i class="ph ph-shield text-white/70 text-[13px]"></i>
<span class="text-[11px] uppercase tracking-wide text-white/70">Fail2Ban Konfiguration</span>
</div>
<button wire:click="save"
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20">
<i class="ph ph-floppy-disk text-[14px]"></i> Speichern & Reload
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-white/60 text-sm mb-1">Bantime (Sekunden)</label>
<input type="number" wire:model.defer="bantime"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
<p class="mt-1 text-xs text-white/45">Standard-Sperrzeit.</p>
</div>
<div>
<label class="block text-white/60 text-sm mb-1">Max. Bantime (Sekunden)</label>
<input type="number" wire:model.defer="max_bantime"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
<p class="mt-1 text-xs text-white/45">Obergrenze bei dynamischer Erhöhung.</p>
</div>
<div>
<label class="block text-white/60 text-sm mb-1">Findtime (Sekunden)</label>
<input type="number" wire:model.defer="findtime"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
<p class="mt-1 text-xs text-white/45">Zeitraum für Wiederholungen.</p>
</div>
<div>
<label class="block text-white/60 text-sm mb-1">Max. Retry</label>
<input type="number" wire:model.defer="max_retry"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
<p class="mt-1 text-xs text-white/45">Fehlversuche bis Bann.</p>
</div>
<div class="md:col-span-2">
<label class="inline-flex items-center gap-2 cursor-pointer select-none group">
<input type="checkbox" wire:model.defer="bantime_increment" class="peer sr-only">
<span
class="w-5 h-5 flex items-center justify-center rounded-md border border-white/15 bg-white/5 peer-checked:bg-emerald-500/20 peer-checked:border-emerald-400/40">
<i class="ph ph-check text-[12px] text-emerald-300 opacity-0 peer-checked:opacity-100"></i>
</span>
<span class="text-white/80 text-sm">Bantime dynamisch erhöhen (increment)</span>
</label>
</div>
<div class="md:col-span-2">
<label class="block text-white/60 text-sm mb-1">Erhöhungs-Faktor</label>
<input type="number" step="0.1" wire:model.defer="bantime_factor"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
<p class="mt-1 text-xs text-white/45">Multiplikator (z. B. 1.5).</p>
</div>
</div>
</div>
<div class="glass-card p-5">
<livewire:ui.security.fail2ban-banlist/>
</div>
<div class="glass-card p-5">
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1 mb-4">
<i class="ph ph-info text-white/70 text-[13px]"></i>
<span class="text-[11px] uppercase tracking-wide text-white/70">Hinweise</span>
</div>
<ul class="list-disc list-inside text-sm text-white/60 space-y-1">
<li><strong>bantime.increment</strong> = true bedeutet, dass sich die Sperrzeit bei wiederholten
Angriffen erhöht (z. B. 1 h 1.5 h 2.25 h ).
</li>
<li>Die SQLite-Datenbank befindet sich unter <code>/var/lib/fail2ban/fail2ban.sqlite3</code>.</li>
<li>Alle Änderungen hier werden nach Klick auf <em>„Speichern & Reload“</em> sofort aktiv.</li>
</ul>
</div>
</div>
{{-- RIGHT 1/3 --}}
<div class="space-y-5">
<div class="glass-card p-5">
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1 mb-3">
<i class="ph ph-list text-white/70 text-[13px]"></i>
<span class="text-[11px] uppercase tracking-wide text-white/70">Whitelist</span>
</div>
@forelse($whitelist as $ip)
<div
class="flex items-center justify-between rounded-lg border border-white/10 bg-white/[0.03] px-3 py-2 mb-2">
<span class="text-white/80 text-sm">{{ $ip }}</span>
<button class="text-[12px] px-2 py-0.5 rounded border border-white/10 hover:border-white/20"
wire:click="$dispatch('openModal',{component:'ui.security.modal.fail2ban-ip-modal',arguments:{mode:'remove',type:'whitelist',ip:'{{ $ip }}'}})">
Entfernen
</button>
</div>
@empty
<div class="text-sm text-white/50">Keine Einträge.</div>
@endforelse
<button class="primary-btn w-full justify-center mt-2"
wire:click="$dispatch('openModal',{component:'ui.security.modal.fail2ban-ip-modal',arguments:{type:'whitelist'}})">
IP hinzufügen
</button>
</div>
<div class="glass-card p-5">
<div
class="inline-flex items-center gap-2 rounded-full bg-rose-500/10 border border-rose-400/30 px-2.5 py-1 mb-3">
<i class="ph ph-hand text-rose-300 text-[13px]"></i>
<span class="text-[11px] uppercase tracking-wide text-rose-300">Blacklist</span>
</div>
@forelse($blacklist as $ip)
<div
class="flex items-center justify-between rounded-lg border border-white/10 bg-white/[0.03] px-3 py-2 mb-2">
<span class="text-white/80 text-sm">{{ $ip }}</span>
<button class="text-[12px] px-2 py-0.5 rounded border border-white/10 hover:border-white/20"
wire:click="$dispatch('openModal',{component:'ui.security.modal.fail2ban-ip-modal',arguments:{mode:'remove',type:'blacklist',ip:'{{ $ip }}'}})">
Entfernen
</button>
</div>
@empty
<div class="text-sm text-white/50">Keine Einträge.</div>
@endforelse
<button
class="text-[13px] w-full px-3 py-2 rounded-xl border border-rose-400/30 bg-rose-500/10 text-rose-200 hover:border-rose-400/50"
wire:click="$dispatch('openModal',{component:'ui.security.modal.fail2ban-ip-modal',arguments:{type:'blacklist'}})">
IP hinzufügen
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,55 @@
<div class="p-5">
{{-- Header --}}
<div class="flex items-center justify-between mb-4">
<div class="inline-flex items-center gap-2 rounded-full
{{ $type === 'blacklist' ? 'bg-rose-500/10 border border-rose-400/30' : 'bg-white/5 border border-white/10' }}
px-2.5 py-1">
<i class="ph {{ $type === 'blacklist' ? 'ph-hand text-rose-300' : 'ph-list text-white/70' }} text-[13px]"></i>
<span class="text-[11px] uppercase tracking-wide
{{ $type === 'blacklist' ? 'text-rose-300' : 'text-white/70' }}">
{{ strtoupper($type) }} {{ $mode === 'add' ? 'hinzufügen' : 'entfernen' }}
</span>
</div>
<button type="button" wire:click="$dispatch('closeModal')"
class="rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-white/70 hover:text-white">
Schließen
</button>
</div>
{{-- Body --}}
<div class="space-y-3">
@if($mode === 'add')
<div>
<label class="block text-white/60 text-sm mb-1">IP oder CIDR</label>
<input type="text" wire:model.defer="ip" placeholder="z. B. 203.0.113.4 oder 203.0.113.0/24 oder 2001:db8::/32"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
@error('ip') <p class="text-sm text-rose-400 mt-1">{{ $message }}</p> @enderror
</div>
<button wire:click="save"
class="primary-btn w-full justify-center">
{{ $type === 'blacklist' ? 'Zur Blacklist hinzufügen & bannen' : 'Zur Whitelist hinzufügen' }}
</button>
@if($type === 'blacklist')
<p class="text-xs text-white/50 mt-2">
Wird sofort im Jail <code>mailwolt-blacklist</code> gebannt (bantime = permanent).
</p>
@endif
@else
<div class="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2">
<div class="text-white/80 text-sm">IP: {{ $prefill ?? $ip }}</div>
<div class="text-white/50 text-xs">Wird aus der {{ $type }} entfernt
@if($type === 'blacklist') und im Blacklist-Jail entbannt @endif.
</div>
</div>
<button wire:click="remove"
class="text-[13px] w-full px-3 py-2 rounded-xl border
{{ $type === 'blacklist'
? 'border-rose-400/40 bg-rose-500/10 text-rose-200 hover:border-rose-400/70'
: 'border-white/20 bg-white/5 text-white/80 hover:border-white/40' }}">
Entfernen
</button>
@endif
</div>
</div>

View File

@ -1,5 +1,5 @@
<div class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5" <div class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5"
wire:poll.2s="refresh"> @if($running) wire:poll.2s="refresh" @endif>
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full"> <div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">

View File

@ -0,0 +1,33 @@
<div class="space-y-4">
<div>
<label class="block text-white/60 text-sm mb-1">Mailserver-Domain (fix)</label>
<input type="text" value="{{ $mail_domain_readonly }}" disabled
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.06] px-3 text-white/60 cursor-not-allowed">
<p class="mt-1 text-xs text-white/45">Wird aus ENV/Config gelesen und ist nicht änderbar.</p>
</div>
<div>
<label class="block text-white/60 text-sm mb-1">UI-Domain</label>
<input type="text" wire:model.defer="ui_domain" placeholder="z. B. ui.deinedomain.tld"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
@error('ui_domain') <p class="text-xs text-rose-400 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-white/60 text-sm mb-1">Webmail-Domain</label>
<input type="text" wire:model.defer="webmail_domain" placeholder="z. B. mail.deinedomain.tld"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
@error('webmail_domain') <p class="text-xs text-rose-400 mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex justify-end">
<button wire:click="save"
class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-white/80 hover:text-white hover:border-white/20">
Speichern
</button>
</div>
<div class="mt-3 text-xs text-white/45">
TLS/Redirect ist systemweit immer erzwungen (HTTPS). ACME/Zertifikate haben ihren eigenen Reiter.
</div>
</div>

View File

@ -0,0 +1,33 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{-- Sprache --}}
<div>
<label class="block text-white/60 text-sm mb-1">Sprache</label>
<select wire:model.defer="locale"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
@foreach (config('mailwolt.language') as $key => $lang)
<option value="{{ $lang['locale'] }}">{{ $lang['label'] }}</option>
@endforeach
</select>
@error('locale') <p class="text-xs text-rose-400 mt-1">{{ $message }}</p> @enderror
</div>
{{-- Zeitzone --}}
<div>
<label class="block text-white/60 text-sm mb-1">Zeitzone</label>
<select wire:model.defer="timezone"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
@foreach (DateTimeZone::listIdentifiers() as $tz)
<option value="{{ $tz }}">{{ $tz }}</option>
@endforeach
</select>
@error('timezone') <p class="text-xs text-rose-400 mt-1">{{ $message }}</p> @enderror
</div>
{{-- Actions: immer unten rechts, volle Breite, rechts ausgerichtet --}}
<div class="md:col-span-2 flex justify-end">
<button wire:click="save"
class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-white/80 hover:text-white hover:border-white/20">
Speichern
</button>
</div>
</div>

View File

@ -0,0 +1,27 @@
<div class="space-y-4">
<label class="flex items-center gap-3">
<input type="checkbox" wire:model.defer="twofa_enabled" class="h-4 w-4">
<span class="text-white/80">Zwei-Faktor-Authentifizierung aktivieren</span>
</label>
<div>
<label class="block text-white/60 text-sm mb-1">Login-Rate-Limit (Versuche/Minute)</label>
<input type="number" min="1" max="100" wire:model.defer="rate_limit"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
@error('rate_limit') <p class="text-xs text-rose-400 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-white/60 text-sm mb-1">Minimale Passwortlänge</label>
<input type="number" min="6" max="128" wire:model.defer="password_min"
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
@error('password_min') <p class="text-xs text-rose-400 mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex justify-end">
<button wire:click="save"
class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-white/80 hover:text-white hover:border-white/20">
Speichern
</button>
</div>
</div>

View File

@ -1 +1,10 @@
<?php @extends('layouts.app')
@section('title', 'Fail2Ban')
@section('header_title', 'Fail2Ban')
@section('content')
<livewire:ui.security.fail2ban-settings />
@endsection

View File

@ -1,14 +1,3 @@
{{-- resources/views/ui/system/settings.blade.php --}}
{{--@extends('layouts.app')--}}
{{--@section('title', 'System · Einstellungen')--}}
{{--@section('header_title', 'System · Einstellungen')--}}
{{--@section('content')--}}
{{-- <div class="glass-card p-5">--}}
{{-- <livewire:ui.system.settings-form />--}}
{{-- </div>--}}
{{--@endsection--}}
{{-- resources/views/ui/system/settings/index.blade.php --}} {{-- resources/views/ui/system/settings/index.blade.php --}}
@extends('layouts.app') @extends('layouts.app')
@ -55,7 +44,7 @@
</div> </div>
{{-- Livewire-Form (Allgemein) --}} {{-- Livewire-Form (Allgemein) --}}
<livewire:ui.system.general-form /> <livewire:ui.system.form.general-form />
</div> </div>
</section> </section>

View File

@ -37,14 +37,16 @@ Route::middleware('auth.user')->name('ui.')->group(function () {
}); });
#DOMAIN ROUTES #DOMAIN ROUTES
Route::name('domain.')->group(function () { Route::name('domains.')->group(function () {
Route::get('/domains', [DomainDnsController::class, 'index'])->name('index'); Route::get('/domains', [DomainDnsController::class, 'index'])->name('index');
}); });
#MAIL ROUTES #MAIL ROUTES
Route::name('mail.')->group(function () { Route::name('mail.')->group(function () {
Route::get('/mailboxes', [MailboxController::class, 'index'])->name('mailbox.index'); Route::get('/mailboxes', [MailboxController::class, 'index'])->name('mailboxes.index');
Route::get('/aliases', [AliasController::class, 'index'])->name('aliases.index'); Route::get('/aliases', [AliasController::class, 'index'])->name('aliases.index');
Route::get('/quarantine', function () {return 'Quarantäne';})->name('quarantine.index');
Route::get('/queues', function () {return 'Queues';})->name('queues.index');
}); });
#LOGOUT ROUTE #LOGOUT ROUTE

View File