Compare commits

...

55 Commits

Author SHA1 Message Date
boban 97cc0091c0 Fix: 419 Session-Fehler leitet direkt zum Login weiter statt Livewire-Dialog 2026-04-30 18:41:19 +02:00
boban eeae972d8b Feat: RSpamd local_addrs (Loopback) automatisch in ensure_system setzen 2026-04-29 19:28:54 +02:00
boban 16fa6d3f4e Fix: Fehlerseiten als individuelle Dateien (4xx-Catch-all Laravel-Bug umgangen) 2026-04-29 19:05:49 +02:00
boban 04e28903ea Fix: Backup-Script direkt auf clubird-Namen umgestellt, Workaround entfernt 2026-04-29 18:59:37 +02:00
boban 4efc014a15 Fix: Backup-Dateiname von mailwolt_ auf clubird_ umbenennen nach externem Script 2026-04-29 18:54:56 +02:00
boban 2cdcb5ec38 Feat: Fehlerseiten 4xx + 5xx im CluBird-Design 2026-04-29 18:53:19 +02:00
boban c313582b0b Fix: UI-Routes auf UI-Domain beschränkt + config:cache nach Update 2026-04-29 18:47:21 +02:00
boban eff76f6568 Fix: Webmail-Root leitet auf Webmail-Login statt UI-Login weiter 2026-04-29 18:41:01 +02:00
boban c48a5c7e02 Fix: config-Key auf clubird umgestellt (Backup-Namen + Webmail-Domain-Binding) 2026-04-29 18:37:54 +02:00
boban d190beadcf Feat: Sidebar-Logo auf CluBird-Design + Syne-Font aktualisiert 2026-04-27 20:58:08 +02:00
boban f1c1cf55da Feat: --font-syne in @theme registriert 2026-04-27 20:55:39 +02:00
boban c94f90bcde Fix: Syne-Font lokal eingebunden statt Google Fonts 2026-04-27 20:38:57 +02:00
boban 0fedff759c Feat: CluBird-Logo + Syne-Font auf Login- und Webmail-Seite 2026-04-27 20:33:51 +02:00
boban dec95d5803 Feat: Domains-Button zeigt Spinner + disabled-State beim Speichern 2026-04-27 20:25:14 +02:00
boban 1ae3c7c54a Fix: mailwolt-apply-hostname in update.sh eingetragen (sbin + sudoers) 2026-04-27 20:20:02 +02:00
boban fce4f43833 Fix: mailwolt-apply-hostname Script + myhostname/aliases in apply-domains integriert 2026-04-27 20:15:21 +02:00
boban 6710827b81 Fix: Postfix myhostname + /etc/aliases beim Domain-Speichern automatisch aktualisieren 2026-04-27 20:09:03 +02:00
boban f20a30839f Fix: 2FA-Setup funktioniert jetzt korrekt — Profilseite + secret column fix 2026-04-27 19:17:29 +02:00
boban 3bb9afefc3 Fix: Logout-Methode in LoginController hinzugefügt 2026-04-27 19:02:31 +02:00
boban 5c8a03cc45 Fix: 503-Seite lädt nicht mehr blind alle 3s neu, sondern wartet bis /ping antwortet 2026-04-27 06:21:56 +02:00
boban 05cc37271c Fix: mailwolt-installer aus git-Index entfernt (eingebettetes Repo) 2026-04-27 06:18:10 +02:00
boban 7a89493efd Feat: Standard DKIM-Selektor von mwl1 auf clb1 geändert 2026-04-27 06:18:05 +02:00
boban afce3d9687 Feat: Quarantäne pro Zeile und pro Tab löschen (lokal via Cache) 2026-04-27 06:16:03 +02:00
boban 0beb12569b Feat: Quarantäne-Verlauf leeren Button (RSpamd historyreset) 2026-04-27 06:11:41 +02:00
boban be3502f197 Fix: Postfix mydestination mit vollem Pfad /usr/sbin/postconf und korrektem myhostname 2026-04-27 06:05:30 +02:00
boban 315608a108 Fix: Postfix mydestination ergänzt damit System-Mails lokal zugestellt werden 2026-04-27 05:58:15 +02:00
boban 30d2c28504 Fix: UpdateCard dispatcht Toast+closeModal gleichzeitig; Fail2ban Dienst-Badge nicht gestreckt 2026-04-27 05:39:02 +02:00
boban b7e411be82 Fix: artisan_up ans Ende verschoben – 503-Seite bleibt bis alles fertig ist 2026-04-27 05:30:53 +02:00
boban 93699108eb Feat: Fail2ban-Banlist mit Grid-Layout für saubere Spaltenausrichtung 2026-04-27 05:28:55 +02:00
boban ff28b98555 Fix: Fail2ban Restzeit via Python3-Script statt sqlite3-CLI (nicht installiert) 2026-04-27 05:20:10 +02:00
boban 3e157f4e56 Fix: Update-Modal wartet auf [DONE]-Marker im Log bevor done-State angezeigt wird 2026-04-27 04:17:21 +02:00
boban d84537b343 Fix: Fail2ban Restzeit-Fallback bei abgelaufenem DB-Eintrag + Update-Modal 2s Verzögerung vor done-State 2026-04-27 04:10:02 +02:00
boban 21015e69a3 Fix: Fail2ban SQLite-Abfrage nicht von is_readable abhängig machen (www-data hat kein Lesezugriff) 2026-04-27 04:05:55 +02:00
boban fc0ede4840 Feat: Fail2ban-Banlist zeigt Restlaufzeit und lesbaren Dienst-Namen 2026-04-27 04:03:01 +02:00
boban aaeea2bb86 Fix: Fail2Ban-Dateien auf clubird umbenannt (clubird-fail2ban, clubird-jails.local) 2026-04-27 03:54:53 +02:00
boban f266228fcf Fix: Fail2Ban Sudoers + Jails automatisch via ensure_system einrichten 2026-04-27 03:53:29 +02:00
boban 41de455826 Add: Logs-Button in UpdateCard + logos.svg 2026-04-27 03:50:20 +02:00
boban 49463bf34d Fix: Tab-URL-Keys auf Englisch (general, domains, backup, notifications) 2026-04-27 03:41:34 +02:00
boban eb77ec3342 Feat: Einstellungs-Tabs auf Deutsch (sicherung/meldungen) + Sidebars für alle Tabs 2026-04-27 03:40:40 +02:00
boban ff6002ca0b Feat: Einstellungen mit URL-Tabs (Allgemein, Domains & SSL, Backup, Benachrichtigungen) 2026-04-27 03:35:59 +02:00
boban 1f013b6dcd Fix: 503-Seite vollständig wiederhergestellt (fehlender Body/Ende-Tag) 2026-04-27 03:31:41 +02:00
boban dcb4456ba6 Fix: Update-Modal auf v403-Basis zurückgesetzt (ohne Slider) 2026-04-27 03:27:36 +02:00
boban 2fb66df19e Fix: Update-Modal – nur Ping-Animation und Slider entfernt, Basis v406 2026-04-27 03:25:57 +02:00
boban f756789cc4 Fix: Update-Modal ohne Slider und Ping-Dots, Spinner im Header 2026-04-27 03:18:22 +02:00
boban 32cf27bcd2 Fix: Update-Modal ohne Alpine.js – verhindert leeres Modal bei laufendem Update 2026-04-27 03:17:47 +02:00
boban 74600185ba Redesign: Update-Modal mit CluBird-Icon, Progress-Bar und besserem Log-Bereich 2026-04-27 03:14:28 +02:00
boban 919c2013f9 Design: 503-Seite finalisiert — nur Spinner, kein Bar/Dots 2026-04-27 03:11:31 +02:00
boban e23d9058ee Fix: 503-Seite zeigt immer Update-Ansicht — PHP-FPM Neustart erzeugt keinen Maintenance-Context 2026-04-27 02:58:36 +02:00
boban b48c74b676 Fix: 503 Wartungsmodus-Erkennung via storage/framework/maintenance.php statt Exception-Klasse 2026-04-27 02:57:28 +02:00
boban e49b1f6c4c Fix: 503-Seite unterscheidet Wartungsmodus (Update) von echtem Serverfehler 2026-04-27 02:55:29 +02:00
boban f473ec66d4 Fix: 503-Seite auf altes Layout zurück + CluBird-Icon + 3s Auto-Refresh 2026-04-27 02:54:49 +02:00
boban 22b6a9e0f2 Redesign: 503-Seite mit CluBird-Icon, Progress-Bar und Schritt-Anzeige 2026-04-27 02:52:20 +02:00
boban 93bcb56605 Rename: WoltGuard → CluGuard in UI-Labels 2026-04-27 02:48:48 +02:00
boban b6475fea75 Fix: Pipe-Signaturen durch $aliases ersetzen — clubird:* Befehle waren nicht aufrufbar 2026-04-27 02:46:38 +02:00
boban 14901a40e3 Fix: displayLatest nur anzeigen wenn tatsächlich Update verfügbar (kein Stale-Cache-Anzeige) 2026-04-27 02:30:46 +02:00
61 changed files with 1786 additions and 527 deletions

View File

@ -17,7 +17,7 @@ class BackupRun extends Command
$job->update(['status' => 'running']);
$policy = $job->policy;
$tmpDir = sys_get_temp_dir() . '/mailwolt_backup_' . $job->id;
$tmpDir = sys_get_temp_dir() . '/clubird_backup_' . $job->id;
mkdir($tmpDir, 0700, true);
$sources = [];
@ -37,7 +37,7 @@ class BackupRun extends Command
}
}
$this->finalize($job, $exitCode, implode("\n", array_slice($output, -30)), $artifact);
$this->finalize($job, $exitCode, implode("\n", array_slice($output, -30)), $artifact);
$this->cleanTmp($tmpDir);
return $exitCode === 0 ? self::SUCCESS : self::FAILURE;
}
@ -97,12 +97,12 @@ class BackupRun extends Command
$outDir = storage_path('app/backups');
if (!is_dir($outDir) && !mkdir($outDir, 0750, true) && !is_dir($outDir)) {
// Final fallback: /tmp (always writable)
$outDir = sys_get_temp_dir() . '/mailwolt_backups';
$outDir = sys_get_temp_dir() . '/clubird_backups';
mkdir($outDir, 0750, true);
}
$stamp = now()->format('Y-m-d_H-i-s');
$outFile = "{$outDir}/mailwolt_{$stamp}.tar.gz";
$outFile = "{$outDir}/clubird_{$stamp}.tar.gz";
$srcArgs = implode(' ', array_map('escapeshellarg', $sources));
$tarOutput = [];

View File

@ -6,7 +6,8 @@ use Illuminate\Console\Command;
class CheckUpdates extends Command
{
protected $signature = 'clubird:check-updates|mailwolt:check-updates';
protected $signature = 'clubird:check-updates';
protected $aliases = ['mailwolt:check-updates'];
protected $description = 'Check for newer CluBird releases via git tags';
public function handle(): int

View File

@ -6,7 +6,8 @@ use Illuminate\Console\Command;
class MailwoltRestart extends Command
{
protected $signature = 'clubird:restart-services|mailwolt:restart-services';
protected $signature = 'clubird:restart-services';
protected $aliases = ['mailwolt:restart-services'];
protected $description = 'Restart or reload MailWolt-related system services';
public function handle(): int

View File

@ -6,7 +6,8 @@ use Illuminate\Console\Command;
class MigrateEnvReverb extends Command
{
protected $signature = 'clubird:migrate-env-reverb|mailwolt:migrate-env-reverb';
protected $signature = 'clubird:migrate-env-reverb';
protected $aliases = ['mailwolt:migrate-env-reverb'];
protected $description = 'Migriert veraltete REVERB_* .env-Werte auf Domain-Basis';
public function handle(): int

View File

@ -7,11 +7,12 @@ use Illuminate\Console\Command;
class WizardDomains extends Command
{
protected $signature = 'clubird:wizard-domains|mailwolt:wizard-domains
protected $signature = 'clubird:wizard-domains
{--ui= : UI-Domain}
{--mail= : Mail-Domain}
{--webmail= : Webmail-Domain}
{--ssl=1 : SSL automatisch (1/0)}';
protected $aliases = ['mailwolt:wizard-domains'];
protected $description = 'Wizard: Domains einrichten mit Status-Dateien';

View File

@ -4,11 +4,20 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class LoginController extends Controller
{
public function show()
{
return view('auth.login'); // enthält @livewire('login-form')
return view('auth.login');
}
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}

View File

@ -21,7 +21,7 @@ class InstallDkimKey implements ShouldQueue
public int $dkimKeyId,
public string $privPath,
public string $dnsTxtContent,
public string $selector = 'mwl1',
public string $selector = 'clb1',
) {}
public function handle(): void

View File

@ -24,7 +24,7 @@ class DkimStatus extends Component
?: optional(
$domain->dkimKeys()->where('is_active', true)->latest()->first()
)->selector
?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
}
/**
@ -46,7 +46,7 @@ class DkimStatus extends Component
// public function regenerate(?string $selector = null): void
// {
// $selector = $selector
// ?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1'));
// ?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1'));
//
// Log::info('DKIM regenerate() CLICKED', [
// 'domain' => $this->domain->domain,
@ -79,7 +79,7 @@ class DkimStatus extends Component
public function regenerate(?string $selector = null): void
{
$selector = $selector
?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1'));
?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1'));
Log::info('DKIM regenerate() CLICKED', [
'domain' => $this->domain->domain,
@ -126,7 +126,7 @@ class DkimStatus extends Component
public function render(): View
{
$sel = $this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
$sel = $this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
$dkimOk = $this->isDkimReady($this->domain->domain, $sel);
return view('livewire.ui.domain.dkim-status', compact('dkimOk'));

View File

@ -52,7 +52,7 @@ class DomainCreateModal extends ModalComponent
$this->max_quota_per_mailbox_mb = config('mailpool.defaults.max_quota_per_mailbox_mb', 3072);
$this->total_quota_mb = (int)config('mailpool.defaults.total_quota_mb', 10240);
$this->dkim_selector = (string) config('mailpool.defaults.dkim_selector', 'mwl1');
$this->dkim_selector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
$this->dkim_bits = (int) config('mailpool.defaults.dkim_bits', 2048);
// Speicherpool-Grenze

View File

@ -92,7 +92,7 @@ class DomainDnsModal extends ModalComponent
$dmarc = "v=DMARC1; p=none; rua=mailto:dmarc@{$this->domainName}; pct=100";
$dkim = DB::table('dkim_keys')->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
$selector = $dkim ? $dkim->selector : 'mwl1';
$selector = $dkim ? $dkim->selector : 'clb1';
$dkimHost = "{$selector}._domainkey.{$this->domainName}";
$dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt), 'v=')
? 'v=DKIM1; k=rsa; p=' . trim($dkim->public_key_txt)
@ -674,7 +674,7 @@ class DomainDnsModal extends ModalComponent
// $dmarc = "v=DMARC1; p=none; rua=mailto:dmarc@{$this->domainName}; pct=100";
//
// $dkim = DB::table('dkim_keys')->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
// $selector = $dkim ? $dkim->selector : 'mwl1';
// $selector = $dkim ? $dkim->selector : 'clb1';
// $dkimHost = "{$selector}._domainkey.{$this->domainName}";
// $dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt), 'v=')
// ? 'v=DKIM1; k=rsa; p=' . trim($dkim->public_key_txt)
@ -1059,7 +1059,7 @@ class DomainDnsModal extends ModalComponent
//
// $dkim = DB::table('dkim_keys')
// ->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
// $selector = $dkim ? $dkim->selector : 'mwl1';
// $selector = $dkim ? $dkim->selector : 'clb1';
// $dkimHost = "{$selector}._domainkey.{$this->domainName}";
// $dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt), 'v=')
// ? 'v=DKIM1; k=rsa; p=' . $dkim->public_key_txt
@ -1179,7 +1179,7 @@ class DomainDnsModal extends ModalComponent
//// $mxHost = config('clubird.mx_fqdn', 'mx.' . $this->domain->domain);
//// $selector = optional(
//// DkimKey::where('domain_id', $this->domain->id)->where('is_active', true)->first()
//// )->selector ?? 'mwl1';
//// )->selector ?? 'clb1';
////
//// $dkimTxt = optional(
//// DkimKey::where('domain_id', $this->domain->id)->where('is_active', true)->first()

View File

@ -38,7 +38,7 @@ class DnsDkim extends Component
{
$domain = Domain::findOrFail($id);
$selector = optional($domain->dkimKeys()->where('is_active', true)->latest()->first())->selector
?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
try {
/** @var DkimService $svc */
@ -61,7 +61,7 @@ class DnsDkim extends Component
public function render()
{
$defaultSelector = (string) config('mailpool.defaults.dkim_selector', 'mwl1');
$defaultSelector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
$mapped = Domain::where('is_server', false)
->with(['dkimKeys' => fn($q) => $q->where('is_active', true)->latest()])

View File

@ -29,6 +29,48 @@ class QuarantineList extends Component
#[On('quarantine:updated')]
public function refresh(): void {}
// ── Löschen (lokal via Cache, RSpamd hat keine per-entry API) ────────────
private function hiddenKey(): string
{
return 'quarantine.hidden.' . session()->getId();
}
private function getHidden(): array
{
return \Cache::get($this->hiddenKey(), []);
}
private function saveHidden(array $ids): void
{
\Cache::put($this->hiddenKey(), array_values(array_unique($ids)), now()->addDays(7));
}
public function deleteEntry(string $id): void
{
$hidden = $this->getHidden();
$hidden[] = $id;
$this->saveHidden($hidden);
}
public function deleteCurrentTab(): void
{
$all = $this->fetchHistory();
$hidden = $this->getHidden();
$toHide = match($this->filter) {
'suspicious' => array_filter($all, fn($m) => $m['action'] !== 'no action'),
'all' => $all,
default => array_filter($all, fn($m) => $m['action'] === $this->filter),
};
foreach ($toHide as $m) {
$hidden[] = $m['id'];
}
$this->saveHidden($hidden);
$this->resetPage();
}
public function updatedFilter(): void { $this->resetPage(); }
public function updatedSearch(): void { $this->resetPage(); }
public function updatedPerPage(): void { $this->resetPage(); }
@ -43,7 +85,11 @@ class QuarantineList extends Component
public function render()
{
$all = $this->fetchHistory();
$hidden = $this->getHidden();
$all = array_values(array_filter(
$this->fetchHistory(),
fn($m) => !in_array($m['id'], $hidden, true)
));
$suspicious = array_values(array_filter($all, fn($m) => $m['action'] !== 'no action'));

View File

@ -15,22 +15,9 @@ class Fail2banBanlist extends Component
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
* ip:string,jail:string,service:string,permanent:bool,label:string,
* remaining:string,box:string,badge:string,dot:string,btn:string
* }>
*/
public array $rows = [];
@ -75,7 +62,9 @@ class Fail2banBanlist extends Component
continue;
}
$permanent = $this->isPermanent($j, $ip);
$banInfo = $this->getBanInfo($j, $ip);
$permanent = $banInfo['permanent'];
$remaining = $banInfo['remaining'];
if ($permanent) {
$box = 'border-rose-400/30 bg-rose-500/5';
@ -94,9 +83,11 @@ class Fail2banBanlist extends Component
$rows[] = [
'ip' => $ip,
'jail' => $j,
'service' => $this->serviceLabel($j),
'permanent' => $permanent,
'style' => $style,
'label' => $label,
'remaining' => $remaining,
'box' => $box,
'badge' => $badge,
'dot' => $dot,
@ -140,35 +131,63 @@ class Fail2banBanlist extends Component
/* ================= 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
/** Gibt permanent-Flag und verbleibende Zeit für (jail, ip) zurück. */
private function getBanInfo(string $jail, string $ip): array
{
$db = $this->getDbFile();
if ($db === '' || !is_readable($db)) {
// Fallback: Blacklist-Jail ist per Design permanent
return $jail === 'mailwolt-blacklist';
$fallbackPermanent = ($jail === 'mailwolt-blacklist');
$cmd = sprintf('sudo -n /usr/local/sbin/clubird-f2b-baninfo %s %s 2>&1',
escapeshellarg($jail), escapeshellarg($ip));
$out = trim((string)@shell_exec($cmd));
if ($out !== '') {
[$timeofban, $bantime] = array_pad(explode('|', $out), 2, '0');
$timeofban = (int)$timeofban;
$bantime = (int)$bantime;
if ($bantime < 0) {
return ['permanent' => true, 'remaining' => ''];
}
$remaining = ($timeofban + $bantime) - time();
if ($remaining > 0) {
return ['permanent' => false, 'remaining' => $this->formatRemaining($remaining)];
}
}
$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;
// Fallback: DB-Eintrag ist abgelaufen (fail2ban-Neustart setzt Timer intern neu,
// ohne die DB zu aktualisieren) → konfigurierte Jail-Banzeit als Näherung
$cfgBantime = (int)trim($this->f2b('get ' . escapeshellarg($jail) . ' bantime'));
if ($cfgBantime < 0) {
return ['permanent' => true, 'remaining' => ''];
}
if ($cfgBantime > 0) {
return ['permanent' => false, 'remaining' => '≤ ' . $this->formatRemaining($cfgBantime)];
}
$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;
return ['permanent' => $fallbackPermanent, 'remaining' => ''];
}
private function formatRemaining(int $seconds): string
{
if ($seconds <= 0) return 'läuft ab';
if ($seconds < 60) return "noch {$seconds} Sek.";
if ($seconds < 3600) return 'noch ' . (int)ceil($seconds / 60) . ' Min.';
if ($seconds < 86400) return 'noch ' . round($seconds / 3600, 1) . ' Std.';
return 'noch ' . (int)ceil($seconds / 86400) . ' Tage';
}
private function serviceLabel(string $jail): string
{
return match(true) {
$jail === 'sshd' => 'SSH',
$jail === 'dovecot' => 'IMAP/POP3',
str_starts_with($jail, 'postfix') => 'SMTP',
str_starts_with($jail, 'nginx') => 'Web',
$jail === 'recidive' => 'Recidive',
str_contains($jail, 'blacklist') => 'Blacklist',
default => $jail,
};
}
/** Liste aller Jails */
@ -188,13 +207,4 @@ SQL;
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

@ -2,90 +2,54 @@
namespace App\Livewire\Ui\Security\Modal;
use App\Services\TotpService;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use LivewireUI\Modal\ModalComponent;
use Vectorface\GoogleAuthenticator;
class TotpSetupModal extends ModalComponent
{
public string $secret;
public string $otp = '';
public string $qrPng; // PNG Data-URI
public bool $alreadyActive = false;
public string $step = 'scan';
public string $code = '';
public string $secret = '';
public array $recoveryCodes = [];
public string $qrSvg = '';
// << Wichtig: je Modal eigene Breite >>
public static function modalMaxWidth(): string
{
// mögliche Werte: 'sm','md','lg','xl','2xl','3xl','4xl','5xl','6xl','7xl'
return 'xl'; // kompakt für TOTP
}
public static function modalMaxWidth(): string { return 'md'; }
public function mount(): void
{
$user = Auth::user();
$ga = new GoogleAuthenticator();
// Falls User schon Secret hat: wiederverwenden, sonst neues anlegen
$this->secret = $user->totp_secret ?: $ga->createSecret();
$issuer = config('app.name', 'MailWolt');
// getQRCodeUrl(accountName, secret, issuer) => PNG Data-URI
$this->qrPng = $ga->getQRCodeUrl($user->email, $this->secret, $issuer);
$this->alreadyActive = (bool) ($user->two_factor_enabled ?? false);
$totp = app(TotpService::class);
$this->secret = $totp->generateSecret();
$this->qrSvg = $totp->qrCodeSvg(Auth::user(), $this->secret);
}
#[On('security:totp:enable')]
public function verifyAndEnable(string $code): void
public function verify(): void
{
$code = preg_replace('/\D/', '', $code ?? '');
if (strlen($code) !== 6) {
$this->dispatch('toast', body: 'Bitte 6-stelligen Code eingeben.');
$this->validate(['code' => 'required|digits:6']);
$totp = app(TotpService::class);
if (!$totp->verify($this->secret, $this->code)) {
$this->addError('code', 'Ungültiger Code. Bitte erneut versuchen.');
return;
}
$ga = new GoogleAuthenticator();
$ok = $ga->verifyCode($this->secret, $code, 2); // 2 × 30 s Toleranz
if (!$ok) {
$this->dispatch('toast', body: 'Code ungültig. Versuche es erneut.');
return;
}
$user = Auth::user();
$user->totp_secret = $this->secret;
$user->two_factor_enabled = true;
$user->save();
$this->dispatch('totp-enabled');
$this->dispatch('toast', body: 'TOTP aktiviert.');
$this->dispatch('closeModal');
$this->recoveryCodes = $totp->enable(Auth::user(), $this->secret);
$this->step = 'codes';
}
public function disable(): void
public function done(): void
{
$user = Auth::user();
$user->totp_secret = null;
$user->two_factor_enabled = false;
$user->save();
$this->dispatch('totp-disabled');
$this->dispatch('toast', body: 'TOTP deaktiviert.');
$this->dispatch('closeModal');
$this->dispatch('toast', type: 'done', badge: '2FA',
title: 'TOTP aktiviert',
text: 'Zwei-Faktor-Authentifizierung ist jetzt aktiv.', duration: 5000);
$this->dispatch('2fa-status-changed');
$this->closeModal();
}
public function saveAccount() { /* $this->validate(..); user->update([...]) */ }
public function changePassword() { /* validate & set */ }
public function changeEmail() { /* validate, send verify link, etc. */ }
public function openRecovery() { /* optional modal or page */ }
public function logoutOthers() { /* … */ }
public function logoutSession(string $id) { /* … */ }
public function render()
{
return view('livewire.ui.security.modal.totp-setup-modal');
return view('livewire.ui.system.modal.totp-setup-modal');
}
}

View File

@ -34,12 +34,24 @@ class UpdateModal extends ModalComponent
$st = @trim(@file_get_contents(self::STATE_DIR.'/state') ?: '');
$rcRaw = @trim(@file_get_contents(self::STATE_DIR.'/rc') ?: '');
// Log einlesen
$lines = @file(self::LOG, FILE_IGNORE_NEW_LINES) ?: [];
// Nur als "done" gelten wenn [DONE]-Marker im Log steht —
// verhindert dass das Modal fertig zeigt während das Script noch läuft
$logDone = in_array('[DONE]', array_map('trim', $lines), true);
if ($st === 'done' && !$logDone) {
$st = 'running'; // Noch warten bis Log vollständig
}
$this->state = $st ?: 'unknown';
$this->rc = is_numeric($rcRaw) ? (int)$rcRaw : null;
// Log einlesen
$lines = @file(self::LOG, FILE_IGNORE_NEW_LINES) ?: [];
$this->tail = array_slice($lines, -30);
$this->tail = array_slice(
array_filter($lines, fn($l) => trim($l) !== '[DONE]'),
-30
);
$last = trim($this->tail ? end($this->tail) : '');
$last = preg_replace('/^\[\w\]\s*/', '', $last);

View File

@ -0,0 +1,99 @@
<?php
namespace App\Livewire\Ui\System;
use App\Services\TotpService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Profil · Mailwolt')]
class ProfilePage extends Component
{
public string $name = '';
public string $email = '';
public string $current_password = '';
public string $new_password = '';
public string $new_password_confirmation = '';
public bool $totpEnabled = false;
public function mount(): void
{
$u = Auth::user();
$this->name = $u->name ?? '';
$this->email = $u->email ?? '';
$this->totpEnabled = app(TotpService::class)->isEnabled($u);
}
#[On('2fa-status-changed')]
public function refreshTotpStatus(): void
{
$this->totpEnabled = app(TotpService::class)->isEnabled(Auth::user());
}
public function saveProfile(): void
{
$this->validate([
'name' => 'required|string|max:100',
]);
$u = Auth::user();
$u->name = $this->name;
$u->save();
$this->dispatch('toast', type: 'done', badge: 'Profil',
title: 'Gespeichert',
text: 'Profilname wurde aktualisiert.', duration: 4000);
}
public function changePassword(): void
{
if ($this->new_password === '') {
$this->addError('new_password', 'Kein neues Passwort eingegeben.');
return;
}
$this->validate([
'current_password' => 'required|string',
'new_password' => 'required|string|min:8',
'new_password_confirmation' => 'required|same:new_password',
]);
$u = Auth::user();
if (!Hash::check($this->current_password, $u->password)) {
$this->addError('current_password', 'Aktuelles Passwort ist falsch.');
return;
}
$u->password = Hash::make($this->new_password);
$u->save();
$this->reset(['current_password', 'new_password', 'new_password_confirmation']);
$this->dispatch('toast', type: 'done', badge: 'Profil',
title: 'Passwort geändert',
text: 'Dein Passwort wurde aktualisiert.', duration: 4000);
}
public function disableTotp(): void
{
app(TotpService::class)->disable(Auth::user());
session()->forget('2fa_verified');
$this->totpEnabled = false;
$this->dispatch('toast', type: 'done', badge: '2FA',
title: 'TOTP deaktiviert',
text: 'Zwei-Faktor-Authentifizierung wurde deaktiviert.', duration: 5000);
}
public function render()
{
return view('livewire.ui.system.profile-page');
}
}

View File

@ -7,12 +7,18 @@ use App\Models\Setting;
use Illuminate\Support\Facades\Artisan;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Einstellungen · Mailwolt')]
class SettingsForm extends Component
{
#[Url]
public string $tab = 'general';
private const VALID_TABS = ['general', 'domains', 'backup', 'notifications'];
// Allgemein
public string $instance_name = '';
public string $locale = 'de';
@ -223,6 +229,10 @@ class SettingsForm extends Component
$warnings[] = 'System-Domain: ' . $sysmailErr;
}
if ($this->mail_domain) {
$this->applyPostfixHostname($this->mail_domain);
}
$this->loadSslStatus();
if ($warnings) {
@ -492,9 +502,23 @@ class SettingsForm extends Component
file_put_contents($path, $content);
}
private function applyPostfixHostname(string $mailHost): void
{
$safeHost = preg_replace('/[^a-zA-Z0-9.\-]/', '', $mailHost);
if (!$safeHost) return;
$helper = '/usr/local/sbin/mailwolt-apply-hostname';
@shell_exec(sprintf('sudo -n %s %s 2>/dev/null', escapeshellarg($helper), escapeshellarg($safeHost)));
}
public function render()
{
$timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL);
return view('livewire.ui.system.settings-form', compact('timezones'));
if (!in_array($this->tab, self::VALID_TABS, true)) {
$this->tab = 'allgemein';
}
$timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL);
$backupMeta = BackupPolicy::first();
return view('livewire.ui.system.settings-form', compact('timezones', 'backupMeta'));
}
}

View File

@ -144,13 +144,20 @@ class UpdateCard extends Component
$ver = $this->displayCurrent ?? 'aktuelle Version';
$this->progressLine = 'Update abgeschlossen: ' . $ver;
$this->dispatch('toast', type: 'done', badge: 'System',
title: 'Update abgeschlossen',
text: "Mailwolt wurde auf {$ver} aktualisiert.",
duration: 6000);
$this->dispatch('closeModal');
} elseif ($this->rc !== null && $this->rc !== 0 && !$this->postActionsDone) {
$this->postActionsDone = true;
@file_put_contents('/var/lib/mailwolt/update/state', 'complete');
$this->errorLine = "Update fehlgeschlagen (rc={$this->rc}).";
$this->dispatch('toast', type:'error', title:'Update fehlgeschlagen',
text:$this->progressLine ?: 'Bitte Logs prüfen: /var/log/mailwolt-update.log',
badge:'System', duration:0);
$this->dispatch('toast', type: 'error', title: 'Update fehlgeschlagen',
text: $this->progressLine ?: 'Bitte Logs prüfen: /var/log/mailwolt-update.log',
badge: 'System', duration: 0);
$this->dispatch('closeModal');
}
$this->state = 'idle';
@ -249,7 +256,9 @@ class UpdateCard extends Component
$this->displayCurrent = $curNorm ? 'v' . $curNorm : null;
if (!$this->displayLatest && $latNorm) {
if (!$this->hasUpdate) {
$this->displayLatest = null;
} elseif (!$this->displayLatest && $latNorm) {
$this->displayLatest = 'v' . $latNorm;
}
}

View File

@ -133,6 +133,7 @@ class UpdatePage extends Component
if ($this->rc === 0 && !$this->postActionsDone) {
@shell_exec('nohup php /var/www/mailwolt/artisan optimize:clear >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan config:cache >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan health:collect >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan settings:sync >/dev/null 2>&1 &');
$this->postActionsDone = true;
@ -194,7 +195,9 @@ class UpdatePage extends Component
$this->displayCurrent = $curNorm ? 'v' . $curNorm : null;
if (!$this->displayLatest && $latNorm) {
if (!$this->hasUpdate) {
$this->displayLatest = null;
} elseif (!$this->displayLatest && $latNorm) {
$this->displayLatest = 'v' . $latNorm;
}
}

View File

@ -17,7 +17,7 @@ class DomainObserver
{
if ($domain->is_server) return;
$selector = (string) config('mailpool.defaults.dkim_selector', 'mwl1');
$selector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
$bits = (int) config('mailpool.defaults.dkim_bits', 2048);
try {
@ -56,7 +56,7 @@ class DomainObserver
// Selector VOR dem Delete einsammeln (Relation oder direkte Query)
$selectors = DkimKey::where('domain_id', $domain->id)->pluck('selector')->all();
$selectors = $selectors ?: ['mwl1'];
$selectors = $selectors ?: ['clb1'];
foreach ($selectors as $sel) {
$cmd = ['sudo','-n','/usr/local/sbin/mailwolt-remove-dkim', $domain->domain, $sel];
@ -136,7 +136,7 @@ class DomainObserver
// return;
// }
//
// $selector = (string) config('mailpool.defaults.dkim_selector', 'mwl1');
// $selector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
// $bits = (int) config('mailpool.defaults.dkim_bits', 2048);
//
// $res = app(\App\Services\DkimService::class)

View File

@ -15,7 +15,7 @@ class DkimService
/** Erzeugt Keypair & gibt den TXT-Record (ohne Host) zurück. */
public function generateForDomain(Domain $domain, int $bits = 2048, string $selector = null): array
{
$selector = $selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
$selector = $selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
// $dirKey = $this->safeKey($domain->domain);
// $selKey = $this->safeKey($selector, 32);
@ -205,7 +205,7 @@ class DkimService
? $domain->dkimKeys()->pluck('selector')->all()
: \App\Models\DkimKey::whereHas('domain', fn($q) => $q->where('domain', $name))
->pluck('selector')->all();
$keys = $keys ?: ['mwl1'];
$keys = $keys ?: ['clb1'];
} else {
$keys = [$selector];
}
@ -241,7 +241,7 @@ class DkimService
// : \App\Models\DkimKey::whereHas('domain', fn($q) => $q->where('domain', $name))
// ->pluck('selector')->all();
//
// $keys = $keys ?: ['mwl1']; // notfalls versuchen wir Standard
// $keys = $keys ?: ['clb1']; // notfalls versuchen wir Standard
// } else {
// $keys = [$selector];
// }
@ -265,7 +265,7 @@ class DkimService
// public function removeForDomain(Domain|string $domain, ?string $selector = null): void
// {
// $name = $domain instanceof \App\Models\Domain ? $domain->domain : $domain;
// $selector = $selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
// $selector = $selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
//
// // Root-Helper ausführen
// $p = Process::run([

View File

@ -14,7 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
using: function () {
// Webmail must be registered BEFORE web.php so its domain constraint
// takes priority over the catch-all Route::get('/') in web.php.
$wmHost = config('mailwolt.domain.webmail_host');
$wmHost = config('clubird.domain.webmail_host');
if ($wmHost) {
Route::middleware('web')
@ -27,14 +27,23 @@ return Application::configure(basePath: dirname(__DIR__))
->prefix('webmail')
->name('webmail.')
->group(base_path('routes/webmail.php'));
// UI-Routes nur auf UI-Domain binden, damit webmail.* sie nicht beantwortet
$uiHost = config('clubird.domain.ui') && config('clubird.domain.base')
? config('clubird.domain.ui') . '.' . config('clubird.domain.base')
: parse_url(config('app.url'), PHP_URL_HOST);
Route::middleware('web')
->domain($uiHost)
->group(base_path('routes/web.php'));
} else {
Route::middleware('web')
->prefix('webmail')
->name('ui.webmail.')
->group(base_path('routes/webmail.php'));
}
Route::middleware('web')->group(base_path('routes/web.php'));
Route::middleware('web')->group(base_path('routes/web.php'));
}
},
)
->withMiddleware(function (Middleware $middleware): void {

View File

@ -19,7 +19,7 @@ return [
'fallback_du' => (bool) env('MAILPOOL_FALLBACK_DU', true),
'defaults' => [
'dkim_selector' => 'mwl1',
'dkim_selector' => 'clb1',
'dkim_bits' => 2048,
'max_aliases' => (int) env('MAILPOOL_DEFAULT_MAX_ALIASES', 400),

View File

@ -0,0 +1,25 @@
<?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::table('two_factor_methods', function (Blueprint $table) {
$table->text('secret')->nullable()->change();
});
}
public function down(): void
{
Schema::table('two_factor_methods', function (Blueprint $table) {
$table->string('secret', 255)->nullable()->change();
});
}
};

View File

@ -305,7 +305,7 @@ class SystemDomainSeeder extends Seeder
// // DKIM Key erzeugen, falls keiner aktiv existiert
// if (!$systemDomain->dkimKeys()->where('is_active', true)->exists()) {
// [$privPem, $pubTxt] = $this->generateDkimKeyPair();
// $selector = 'mwl1'; // frei wählbar, später rotieren
// $selector = 'clb1'; // frei wählbar, später rotieren
//
// DkimKey::create([
// 'domain_id' => $systemDomain->id,
@ -452,7 +452,7 @@ class SystemDomainSeeder extends Seeder
// // DKIM Key erzeugen, falls keiner aktiv existiert
// if (!$systemDomain->dkimKeys()->where('is_active', true)->exists()) {
// [$privPem, $pubTxt] = $this->generateDkimKeyPair();
// $selector = 'mwl1'; // frei wählbar, später rotieren
// $selector = 'clb1'; // frei wählbar, später rotieren
//
// DkimKey::create([
// 'domain_id' => $systemDomain->id,
@ -569,7 +569,7 @@ class SystemDomainSeeder extends Seeder
// // DKIM Key erzeugen, falls keiner aktiv existiert
// if (! $domain->dkimKeys()->where('is_active', true)->exists()) {
// [$privPem, $pubTxt] = $this->generateDkimKeyPair();
// $selector = 'mwl1'; // frei wählbar, z. B. rotierend später
// $selector = 'clb1'; // frei wählbar, z. B. rotierend später
//
// DkimKey::create([
// 'domain_id' => $domain->id,

418
logos.svg
View File

@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CluBird — Logo</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=DM+Sans:wght@400;500&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #07080e;
--surface: #0b0d16;
--border: #181c2e;
--border2: #252840;
--acc: #6366f1;
--acc-h: #4f46e5;
--acc-l: #818cf8;
--acc-bg: #1a1a3a;
--acc-bd: #2d2f5a;
--t1: #e8eaf6;
--t2: #9a9ec8;
--t3: #4a5070;
--t4: #252840;
}
body {
background: var(--bg);
color: var(--t1);
font-family: 'DM Sans', system-ui, sans-serif;
padding: 40px;
min-height: 100vh;
}
h2 {
font-family: 'DM Sans', sans-serif;
font-size: 10px;
font-weight: 500;
color: var(--t3);
text-transform: uppercase;
letter-spacing: 1.5px;
margin-bottom: 16px;
}
.section { margin-bottom: 48px; }
.row {
display: flex;
gap: 24px;
align-items: flex-start;
flex-wrap: wrap;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 28px 32px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-light {
background: #f0f2ff;
border: 1px solid #d4d8ff;
border-radius: 14px;
padding: 28px 32px;
}
.card-label {
font-size: 9px;
color: var(--t3);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 6px;
}
/* ── Logo Lockup ── */
.logo {
display: flex;
align-items: center;
gap: 11px;
}
.logo-icon {
flex-shrink: 0;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.wordmark {
font-family: 'Syne', sans-serif;
font-weight: 800;
letter-spacing: -0.5px;
line-height: 1;
}
.wordmark .clu { color: var(--t1); }
.wordmark .bird { color: var(--acc); }
.wordmark-light .clu { color: #0f1120; }
.wordmark-light .bird { color: var(--acc); }
.tagline {
font-size: 9px;
font-weight: 500;
color: var(--t3);
text-transform: uppercase;
letter-spacing: 1.8px;
margin-top: 3px;
}
/* ── Divider ── */
.divider {
width: 1px;
height: 40px;
background: var(--border);
margin: 0 8px;
}
/* ── Family ── */
.family {
display: flex;
align-items: center;
gap: 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px 28px;
width: fit-content;
}
.family-item {
display: flex;
align-items: center;
gap: 9px;
}
.plus {
font-size: 16px;
color: var(--border2);
font-weight: 300;
}
.family-name {
font-family: 'Syne', sans-serif;
font-size: 13px;
font-weight: 800;
letter-spacing: -0.3px;
}
.family-sub {
font-size: 8.5px;
color: var(--t3);
text-transform: uppercase;
letter-spacing: 0.8px;
margin-top: 1px;
}
</style>
</head>
<body>
<!-- ── 1. HAUPTLOGO ── -->
<div class="section">
<h2>Hauptlogo — Dark Mode</h2>
<div class="row">
<!-- Groß -->
<div>
<div class="card-label">Groß</div>
<div class="card">
<div class="logo">
<div class="logo-icon" style="width:48px;height:48px;background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);box-shadow:0 0 20px rgba(99,102,241,.35);">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<!-- Körper -->
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<!-- Flügel links oben -->
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!-- Flügel rechts -->
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!-- Auge -->
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<!-- Schwanz / Brust -->
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div>
<div class="wordmark" style="font-size:28px;">
<span class="clu">Clu</span><span class="bird">Bird</span>
</div>
<div class="tagline">Mail Server Management</div>
</div>
</div>
</div>
</div>
<!-- Standard -->
<div>
<div class="card-label">Standard</div>
<div class="card">
<div class="logo">
<div class="logo-icon" style="width:36px;height:36px;background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);box-shadow:0 0 14px rgba(99,102,241,.3);">
<svg width="21" height="21" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div>
<div class="wordmark" style="font-size:21px;">
<span class="clu">Clu</span><span class="bird">Bird</span>
</div>
<div class="tagline">by Aziros</div>
</div>
</div>
</div>
</div>
<!-- Klein / Sidebar -->
<div>
<div class="card-label">Klein / Sidebar</div>
<div class="card">
<div class="logo">
<div class="logo-icon" style="width:28px;height:28px;background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);border-radius:7px;">
<svg width="16" height="16" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="2" fill="white" opacity=".95"/>
</svg>
</div>
<div class="wordmark" style="font-size:16px;">
<span class="clu">Clu</span><span class="bird">Bird</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── 2. LIGHT MODE ── -->
<div class="section">
<h2>Light Mode</h2>
<div class="row">
<div>
<div class="card-label">Standard Light</div>
<div class="card-light">
<div class="logo">
<div class="logo-icon" style="width:36px;height:36px;background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);border-radius:10px;box-shadow:0 4px 12px rgba(99,102,241,.25);">
<svg width="21" height="21" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div>
<div class="wordmark wordmark-light" style="font-size:21px;">
<span class="clu">Clu</span><span class="bird">Bird</span>
</div>
<div class="tagline" style="color:#8892aa;">Mail Server Management</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── 3. ICON VARIANTEN ── -->
<div class="section">
<h2>Icon-Only — alle Größen</h2>
<div class="card" style="display:flex;gap:20px;align-items:flex-end;width:fit-content;">
<!-- 64px -->
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
<div style="width:64px;height:64px;border-radius:16px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;box-shadow:0 0 24px rgba(99,102,241,.4);">
<svg width="38" height="38" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<span style="font-size:9px;color:var(--t3);">64px</span>
</div>
<!-- 48px -->
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
<div style="width:48px;height:48px;border-radius:12px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;box-shadow:0 0 16px rgba(99,102,241,.35);">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<span style="font-size:9px;color:var(--t3);">48px</span>
</div>
<!-- 36px -->
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
<div style="width:36px;height:36px;border-radius:9px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;">
<svg width="21" height="21" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<span style="font-size:9px;color:var(--t3);">36px</span>
</div>
<!-- 24px -->
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
<div style="width:24px;height:24px;border-radius:6px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;">
<svg width="14" height="14" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="2" fill="white"/>
</svg>
</div>
<span style="font-size:9px;color:var(--t3);">24px</span>
</div>
<!-- Favicon -->
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
<div style="width:16px;height:16px;border-radius:4px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;">
<svg width="10" height="10" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="3" stroke-linecap="round" fill="none"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="3" stroke-linecap="round" fill="none"/>
</svg>
</div>
<span style="font-size:9px;color:var(--t3);">Favicon</span>
</div>
<!-- Rund -->
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
<div style="width:48px;height:48px;border-radius:50%;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;box-shadow:0 0 16px rgba(99,102,241,.35);">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<span style="font-size:9px;color:var(--t3);">Rund</span>
</div>
</div>
</div>
<!-- ── 4. WORDMARK VARIANTEN ── -->
<div class="section">
<h2>Wordmark</h2>
<div class="card" style="display:flex;flex-direction:column;gap:16px;width:fit-content;">
<div class="wordmark" style="font-size:32px;"><span class="clu">Clu</span><span class="bird">Bird</span></div>
<div class="wordmark" style="font-size:22px;"><span class="clu">Clu</span><span class="bird">Bird</span></div>
<div style="font-family:'Syne',sans-serif;font-size:18px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--t1);">CLU<span style="color:var(--acc);">BIRD</span></div>
<div style="font-family:'Syne',sans-serif;font-size:16px;font-weight:800;color:var(--t1);">clu<span style="color:var(--acc);">bird</span></div>
</div>
</div>
<!-- ── 5. PRODUKTFAMILIE ── -->
<div class="section">
<h2>Aziros Produktfamilie</h2>
<div class="family">
<!-- CluPilot -->
<div class="family-item">
<div style="width:32px;height:32px;border-radius:8px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;box-shadow:0 0 10px rgba(99,102,241,.3);">
<span style="font-size:11px;font-weight:800;color:#fff;font-family:'Syne',sans-serif;">CP</span>
</div>
<div>
<div class="family-name"><span style="color:var(--t1);">Clu</span><span style="color:var(--acc);">Pilot</span></div>
<div class="family-sub">Infrastruktur</div>
</div>
</div>
<div class="plus">+</div>
<!-- CluBird -->
<div class="family-item">
<div style="width:32px;height:32px;border-radius:8px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;box-shadow:0 0 10px rgba(99,102,241,.3);">
<svg width="18" height="18" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div>
<div class="family-name"><span style="color:var(--t1);">Clu</span><span style="color:var(--acc);">Bird</span></div>
<div class="family-sub">Mail Management</div>
</div>
</div>
<div class="plus">=</div>
<!-- Aziros Suite -->
<div>
<div style="font-family:'Syne',sans-serif;font-size:15px;font-weight:800;color:var(--t1);">Aziros <span style="color:var(--acc);">Suite</span></div>
<div class="family-sub">Infrastructure + Mail</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -3,6 +3,7 @@
@import "../fonts/BaiJamjuree/font.css";
@import "../fonts/Space/font.css";
@import "../fonts/Syne/font.css";
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../vendor/wire-elements/modal/resources/views/*.blade.php';
@ -16,6 +17,7 @@
--font-bai: 'Bai Jamjuree';
--font-space: 'Space Age';
--font-syne: 'Syne';
--color-glass-bg: rgba(17, 24, 39, 0.55);
--color-glass-border: rgba(255, 255, 255, 0.08);
@ -1041,8 +1043,8 @@ select {
background: var(--mw-v); display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 12px rgba(124,58,237,.3);
}
.mw-sb-name { font-size: 13px; font-weight: 700; letter-spacing: .3px; color: var(--mw-t1); }
.mw-sb-sub { font-size: 9.5px; color: var(--mw-t4); letter-spacing: .8px; text-transform: uppercase; margin-top: 1px; }
.mw-sb-name { font-family: 'Syne', sans-serif; font-size: 15px; font-weight: 800; letter-spacing: -.2px; line-height: 1; color: var(--mw-t1); }
.mw-sb-sub { font-size: 9px; color: var(--mw-t4); letter-spacing: 1.6px; text-transform: uppercase; margin-top: 3px; }
.mw-nav { flex: 1; padding: 10px 8px; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; }
.mw-nav::-webkit-scrollbar { display: none; }
@ -1694,6 +1696,8 @@ select {
box-shadow: 0 0 8px rgba(124,58,237,.3);
}
.mbx-btn-primary:hover { background: #6d28d9; }
.mbx-btn-primary:disabled { opacity: .55; cursor: not-allowed; box-shadow: none; }
.mbx-btn-primary:active:not(:disabled) { transform: scale(.97); }
.mbx-btn-mute {
display: flex; align-items: center; gap: 6px;
background: var(--mw-bg3); border: 1px solid var(--mw-b1);

View File

@ -0,0 +1,15 @@
@font-face {
font-display: swap;
font-family: 'Syne';
font-style: normal;
font-weight: 700;
src: url('Syne-Bold.ttf') format('truetype');
}
@font-face {
font-display: swap;
font-family: 'Syne';
font-style: normal;
font-weight: 800;
src: url('Syne-ExtraBold.ttf') format('truetype');
}

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 400,
'type' => 'client',
'title' => 'Ungültige Anfrage',
'desc' => 'Die Anfrage enthält fehlerhafte oder unvollständige Daten.',
'icon' => '<path d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 401,
'type' => 'client',
'title' => 'Nicht angemeldet',
'desc' => 'Bitte melde dich an, um auf diese Seite zuzugreifen.',
'icon' => '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 403,
'type' => 'client',
'title' => 'Kein Zugriff',
'desc' => 'Du hast keine Berechtigung, diese Seite aufzurufen.',
'icon' => '<circle cx="12" cy="12" r="9"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 404,
'type' => 'client',
'title' => 'Seite nicht gefunden',
'desc' => 'Die aufgerufene Seite existiert nicht oder wurde verschoben.',
'icon' => '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 405,
'type' => 'client',
'title' => 'Methode nicht erlaubt',
'desc' => 'Diese HTTP-Methode ist für die aufgerufene URL nicht zulässig.',
'icon' => '<circle cx="12" cy="12" r="9"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 419,
'type' => 'client',
'title' => 'Sitzung abgelaufen',
'desc' => 'Deine Sitzung ist abgelaufen. Lade die Seite neu und versuche es erneut.',
'icon' => '<circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 15"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 422,
'type' => 'client',
'title' => 'Ungültige Eingabe',
'desc' => 'Die übermittelten Daten konnten nicht verarbeitet werden.',
'icon' => '<path d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 429,
'type' => 'client',
'title' => 'Zu viele Anfragen',
'desc' => 'Du hast das Anfrage-Limit erreicht. Bitte warte einen Moment.',
'icon' => '<path d="M13 10V3L4 14h7v7l9-11h-7z"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 500,
'type' => 'server',
'title' => 'Serverfehler',
'desc' => 'Ein interner Fehler ist aufgetreten. Wir wurden benachrichtigt.',
'icon' => '<path d="M13 10V3L4 14h7v7l9-11h-7z"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 502,
'type' => 'server',
'title' => 'Bad Gateway',
'desc' => 'Der Server hat eine ungültige Antwort erhalten. Bitte versuche es gleich erneut.',
'icon' => '<path d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>',
])

View File

@ -3,109 +3,98 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="8">
<title>Wartung · CluBird</title>
<title>Update läuft · CluBird</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
width: 100%;
min-height: 100vh;
background: #0f1117;
color: #e2e4ea;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.card {
width: 100%;
max-width: 420px;
padding: 40px 36px;
max-width: 480px;
padding: 44px 40px;
background: #181c24;
border: 1px solid #2a2e3d;
border-radius: 16px;
border: 1px solid #252938;
border-radius: 18px;
text-align: center;
animation: fadein .4s ease;
}
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.logo {
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 32px;
gap: 14px;
margin-bottom: 40px;
}
.logo-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border-radius: 10px;
position: relative;
width: 42px;
height: 42px;
background: linear-gradient(135deg, #6366f1, #4338ca);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 22px rgba(99,102,241,.45);
}
.logo-text {
font-size: 17px;
font-weight: 600;
color: #e2e4ea;
letter-spacing: -.3px;
.logo-icon::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 16px;
border: 1px solid rgba(99,102,241,.3);
animation: glow 2s ease-in-out infinite;
}
@keyframes glow { 0%, 100% { opacity: .3; } 50% { opacity: 1; } }
.logo-text { font-size: 24px; font-weight: 700; letter-spacing: -.3px; }
.logo-text span { color: #6366f1; }
.spinner {
width: 48px;
height: 48px;
margin: 0 auto 24px;
width: 54px; height: 54px;
margin: 0 auto 28px;
border-radius: 50%;
border: 3px solid #2a2e3d;
border: 3px solid #252938;
border-top-color: #6366f1;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
h1 {
font-size: 18px;
font-weight: 600;
color: #e2e4ea;
margin-bottom: 10px;
letter-spacing: -.2px;
}
p {
font-size: 13.5px;
color: #6b7280;
line-height: 1.6;
}
.dot-row {
display: flex;
justify-content: center;
gap: 6px;
margin-top: 28px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #6366f1;
opacity: .3;
animation: pulse 1.4s ease-in-out infinite;
}
.dot:nth-child(2) { animation-delay: .2s; }
.dot:nth-child(3) { animation-delay: .4s; }
@keyframes pulse { 0%, 80%, 100% { opacity: .3; } 40% { opacity: 1; } }
h1 { font-size: 17px; font-weight: 600; color: #e2e4ea; margin-bottom: 8px; letter-spacing: -.2px; }
p { font-size: 13px; color: #4b5563; line-height: 1.65; }
</style>
</head>
<body>
<div class="card">
<div class="logo">
<div class="logo-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
<div class="card">
<div class="logo">
<div class="logo-icon">
<svg width="22" height="22" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="19" cy="8" r="2" fill="white"/>
</svg>
</div>
<div class="logo-text">Clu<span>Bird</span></div>
</div>
<span class="logo-text">CluBird</span>
<div class="spinner"></div>
<h1>Update wird durchgeführt</h1>
<p>Die Anwendung wird aktualisiert.<br>Diese Seite lädt automatisch neu.</p>
</div>
<div class="spinner"></div>
<h1>Update wird durchgeführt</h1>
<p>Das System wird gerade aktualisiert.<br>Diese Seite lädt in wenigen Sekunden automatisch neu.</p>
<div class="dot-row">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
</div>
<script>
(function(){
function check(){
fetch('/ping',{cache:'no-store'}).then(function(r){
if(r.ok) window.location.reload();
else setTimeout(check,3000);
}).catch(function(){ setTimeout(check,3000); });
}
setTimeout(check,3000);
})();
</script>
</body>
</html>

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 504,
'type' => 'server',
'title' => 'Gateway-Timeout',
'desc' => 'Der Server antwortet nicht rechtzeitig. Bitte versuche es in Kürze erneut.',
'icon' => '<circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 15"/>',
])

View File

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $code }} · CluBird</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; min-height: 100vh;
background: #0f1117; color: #e2e4ea;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.card {
width: 100%; max-width: 480px; padding: 44px 40px;
background: #181c24; border: 1px solid #252938;
border-radius: 18px; text-align: center;
animation: fadein .35s ease;
}
@keyframes fadein { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.logo { display: inline-flex; align-items: center; gap: 14px; margin-bottom: 36px; }
.logo-icon {
position: relative; width: 42px; height: 42px;
background: linear-gradient(135deg, #6366f1, #4338ca);
border-radius: 12px; display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 22px rgba(99,102,241,.45);
}
.logo-icon::after {
content: ''; position: absolute; inset: -4px; border-radius: 16px;
border: 1px solid rgba(99,102,241,.3);
animation: glow 2s ease-in-out infinite;
}
@keyframes glow { 0%, 100% { opacity: .3; } 50% { opacity: 1; } }
.logo-text { font-size: 24px; font-weight: 700; letter-spacing: -.3px; }
.logo-text span { color: #6366f1; }
.err-icon {
width: 56px; height: 56px; margin: 0 auto 20px;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
background: {{ $type === 'server' ? 'rgba(239,68,68,.1)' : 'rgba(99,102,241,.12)' }};
}
.err-icon svg {
width: 26px; height: 26px; fill: none; stroke-width: 1.8;
stroke-linecap: round; stroke-linejoin: round;
stroke: {{ $type === 'server' ? '#f87171' : '#818cf8' }};
}
.err-code {
font-size: 64px; font-weight: 800; line-height: 1;
margin-bottom: 12px; letter-spacing: -2px;
color: {{ $type === 'server' ? '#f87171' : '#6366f1' }};
}
h1 { font-size: 17px; font-weight: 600; color: #e2e4ea; margin-bottom: 8px; letter-spacing: -.2px; }
p { font-size: 13px; color: #4b5563; line-height: 1.65; margin-bottom: 28px; }
.divider { border: none; border-top: 1px solid #252938; margin: 0 0 28px; }
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 9px 20px; border-radius: 9px;
background: #1f2433; border: 1px solid #2d3348;
font-size: 13px; font-weight: 500; text-decoration: none; cursor: pointer;
transition: background .15s, border-color .15s;
color: {{ $type === 'server' ? '#fca5a5' : '#a5b4fc' }};
}
.btn:hover { background: #252b3d; border-color: #3d4565; }
.btn svg { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.btn-primary {
display: inline-flex; align-items: center; gap: 6px;
padding: 9px 20px; border-radius: 9px; margin-left: 8px;
background: linear-gradient(135deg, #6366f1, #4338ca); border: none;
color: #fff; font-size: 13px; font-weight: 500; text-decoration: none;
box-shadow: 0 4px 14px rgba(99,102,241,.35); transition: opacity .15s;
}
.btn-primary:hover { opacity: .88; }
.btn-primary svg { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
</style>
</head>
<body>
<div class="card">
<div class="logo">
<div class="logo-icon">
<svg width="22" height="22" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="19" cy="8" r="2" fill="white"/>
</svg>
</div>
<div class="logo-text">Clu<span>Bird</span></div>
</div>
<div class="err-icon">
<svg viewBox="0 0 24 24">{!! $icon !!}</svg>
</div>
<div class="err-code">{{ $code }}</div>
<h1>{{ $title }}</h1>
<p>{{ $desc }}</p>
<hr class="divider">
@if($type === 'server')
<a class="btn" href="javascript:location.reload()">
<svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.1-6.87"/></svg>
Neu laden
</a>
@else
<a class="btn" href="javascript:history.back()">
<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
Zurück
</a>
@endif
<a class="btn-primary" href="/">
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
Startseite
</a>
</div>
</body>
</html>

View File

@ -18,15 +18,17 @@
<aside class="mw-sidebar" id="mw-sidebar">
<div class="mw-sb-head">
<div class="mw-sb-mark" style="background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);box-shadow:0 0 10px rgba(99,102,241,.35);">
<svg width="15" height="15" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="2" fill="white"/>
<div class="mw-sb-mark" style="background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);box-shadow:0 0 12px rgba(99,102,241,.35);">
<svg width="18" height="18" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div>
<div class="mw-sb-name"><span style="color:var(--mw-t1)">Clu</span><span style="color:#6366f1">Bird</span></div>
<div class="mw-sb-name"><span style="color:#e8eaf6">Clu</span><span style="color:#6366f1">Bird</span></div>
<div class="mw-sb-sub">Control Panel</div>
</div>
</div>
@ -148,11 +150,13 @@
</nav>
<div class="mw-sb-foot">
<div class="mw-avatar">{{ strtoupper(substr(auth()->user()->name ?? 'MW', 0, 2)) }}</div>
<a href="{{ route('ui.profile') }}" style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;overflow:hidden;text-decoration:none;border-radius:7px;padding:2px 4px;margin:-2px -4px;transition:background .15s" onmouseover="this.style.background='var(--mw-bg4)'" onmouseout="this.style.background=''">
<div class="mw-avatar" style="{{ request()->routeIs('ui.profile') ? 'background:var(--mw-v)' : '' }}">{{ strtoupper(substr(auth()->user()->name ?? 'MW', 0, 2)) }}</div>
<div style="min-width:0;flex:1;overflow:hidden;">
<div style="font-size:12px;font-weight:500;color:var(--mw-t2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ auth()->user()->name ?? 'Admin' }}</div>
<div style="font-size:10px;color:var(--mw-t4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ auth()->user()->email ?? '' }}</div>
</div>
</a>
<form method="POST" action="{{ route('ui.logout') }}" style="flex-shrink:0;">
@csrf
<button type="submit" style="background:none;border:none;cursor:pointer;color:var(--mw-t4);padding:4px;" title="Abmelden">
@ -222,14 +226,18 @@
@livewireScripts
@livewire('wire-elements-modal')
<script>
// 503 (Wartungsmodus) → sofortiger Page-Reload statt Livewire-Fehlerdialog
// 503 (Wartungsmodus) → Reload | 419 (Session abgelaufen) → Login
;(function () {
var _fetch = window.fetch
window.fetch = function () {
return _fetch.apply(this, arguments).then(function (resp) {
if (resp.status === 503) {
window.location.reload()
return new Promise(function () {}) // Livewire-Chain abbrechen
return new Promise(function () {})
}
if (resp.status === 419) {
window.location.href = '{{ route('login') }}'
return new Promise(function () {})
}
return resp
})

View File

@ -17,15 +17,21 @@
{{-- Brand --}}
<div style="text-align:center;margin-bottom:28px;">
<div style="display:inline-flex;align-items:center;justify-content:center;
width:52px;height:52px;border-radius:14px;background:var(--mw-v);
margin-bottom:14px;box-shadow:0 4px 18px rgba(109,40,217,.25);">
<svg width="22" height="22" viewBox="0 0 14 14" fill="none">
<rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="white" stroke-width="1.3"/>
<path d="M.5 5l6.5 4 6.5-4" stroke="white" stroke-width="1.3" stroke-linecap="round"/>
width:48px;height:48px;border-radius:13px;
background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);
margin-bottom:14px;box-shadow:0 0 20px rgba(99,102,241,.35);">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div style="font-size:20px;font-weight:700;letter-spacing:-.3px;color:var(--mw-t1);">Webmail</div>
<div style="font-size:12px;color:var(--mw-t4);margin-top:3px;">{{ config('app.name') }}</div>
<div style="font-family:'Syne',sans-serif;font-size:24px;font-weight:800;letter-spacing:-.3px;line-height:1;margin-bottom:4px">
<span style="color:#e8eaf6">Clu</span><span style="color:#6366f1">Bird</span>
</div>
<div style="font-size:11px;color:var(--mw-t4);margin-top:5px;">Webmail</div>
</div>
{{-- Card --}}

View File

@ -1,16 +1,19 @@
<div style="width:100%;max-width:420px;padding:24px 16px">
{{-- Logo --}}
<div style="display:flex;align-items:center;gap:10px;margin-bottom:32px;justify-content:center">
<div style="width:32px;height:32px;background:linear-gradient(135deg,#6366f1,#4f46e5);border-radius:8px;display:flex;align-items:center;justify-content:center;box-shadow:0 0 16px rgba(99,102,241,.4)">
<svg width="16" height="16" viewBox="0 0 18 18" fill="none">
<path d="M9 2L15.5 5.5v7L9 16 2.5 12.5v-7L9 2Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M9 6L12 7.5v3L9 12 6 10.5v-3L9 6Z" fill="white" opacity=".9"/>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:32px;justify-content:center">
<div style="width:36px;height:36px;background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);border-radius:10px;display:flex;align-items:center;justify-content:center;box-shadow:0 0 16px rgba(99,102,241,.35);flex-shrink:0">
<svg width="21" height="21" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div>
<div style="font-size:14px;font-weight:700;letter-spacing:.3px;color:var(--mw-t1)">CLUBIRD</div>
<div style="font-size:11px;color:var(--mw-t4);margin-top:-1px">Control Panel</div>
<div style="font-family:'Syne',sans-serif;font-size:22px;font-weight:800;letter-spacing:-.3px;line-height:1"><span style="color:#e8eaf6">Clu</span><span style="color:#6366f1">Bird</span></div>
<div style="font-size:9px;font-weight:500;color:var(--mw-t4);text-transform:uppercase;letter-spacing:1.8px;margin-top:2px">Mail Server Management</div>
</div>
</div>

View File

@ -7,7 +7,7 @@
@if(!$dkimOk)
<button
wire:click="regenerate('{{ $selector ?? ($domain->dkimKeys()->where('is_active', true)->latest()->value('selector') ?? 'mwl1') }}')"
wire:click="regenerate('{{ $selector ?? ($domain->dkimKeys()->where('is_active', true)->latest()->value('selector') ?? 'clb1') }}')"
wire:loading.attr="disabled"
class="inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium
border border-slate-600/40 bg-slate-800/40 hover:bg-slate-800 disabled:opacity-50">

View File

@ -126,7 +126,7 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 1.5L12.5 4v4.5c0 2.8-2.3 4.5-5.5 5C3.8 13 1.5 11.3 1.5 8.5V4L7 1.5Z" stroke="#22c55e" stroke-width="1.2" stroke-linejoin="round"/><path d="M4.5 7l2 2 3-3" stroke="#22c55e" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div>
<div class="mw-sc-title">WoltGuard</div>
<div class="mw-sc-title">CluGuard</div>
<div class="mw-sc-sub">System-Wächter</div>
</div>
<span class="mw-sc-badge mw-badge-ok">alle Dienste OK</span>

View File

@ -20,6 +20,16 @@
<option value="50">50 / Seite</option>
<option value="100">100 / Seite</option>
</select>
@if($messages->total() > 0)
<button wire:click="deleteCurrentTab"
wire:confirm="Alle Einträge in diesem Tab ausblenden?"
class="mw-btn-del" title="Tab leeren">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none">
<path d="M2 3.5h10M5.5 3.5V2.5h3v1M6 6v4M8 6v4M3 3.5l.7 8h6.6l.7-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Tab leeren
</button>
@endif
</div>
</div>
@ -94,10 +104,16 @@
@endif
</td>
<td class="mq-num">{{ $msg['time'] > 0 ? date('d.m H:i', $msg['time']) : '—' }}</td>
<td>
<td style="display:flex;gap:4px;align-items:center;justify-content:flex-end">
<button wire:click="openMessage('{{ $msg['id'] }}')" class="mq-btn-icon" title="Details">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M7 6.5v4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="7" cy="4.5" r=".7" fill="currentColor"/></svg>
</button>
<button wire:click="deleteEntry('{{ $msg['id'] }}')" class="mq-btn-icon" title="Ausblenden"
style="color:var(--mw-t4);hover:color:var(--rose-400)">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none">
<path d="M2 3.5h10M5.5 3.5V2.5h3v1M6 6v4M8 6v4M3 3.5l.7 8h6.6l.7-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</td>
</tr>
@endforeach

View File

@ -18,15 +18,33 @@
Keine aktiven Banns vorhanden.
</div>
@else
<div style="padding:10px 12px">
{{-- Spalten: Dot | IP | Dienst | Status+Zeit | Button --}}
<div style="padding:6px 12px 10px">
@foreach($rows as $r)
<div class="f2b-row {{ $r['style'] }}">
<div style="display:flex;align-items:center;gap:10px;min-width:0">
<div class="f2b-dot {{ $r['style'] }}"></div>
<code style="font-size:12.5px;color:var(--mw-t1);letter-spacing:.02em;flex-shrink:0">{{ $r['ip'] }}</code>
<span class="f2b-jail-badge">{{ $r['jail'] }}</span>
<span style="font-size:10.5px;color:{{ $r['style'] === 'permanent' ? 'rgba(252,165,165,.8)' : 'rgba(252,211,77,.8)' }}">{{ $r['label'] }}</span>
<div class="f2b-row {{ $r['style'] }}"
style="display:grid;grid-template-columns:8px 140px 80px 1fr 28px;align-items:center;gap:0 10px">
{{-- Dot --}}
<div class="f2b-dot {{ $r['style'] }}" style="justify-self:center"></div>
{{-- IP --}}
<code style="font-size:12px;color:var(--mw-t1);letter-spacing:.02em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $r['ip'] }}</code>
{{-- Dienst --}}
<div style="display:flex;align-items:center;overflow:hidden">
<span class="f2b-jail-badge" title="{{ $r['jail'] }}"
style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $r['service'] }}</span>
</div>
{{-- Status + Restzeit --}}
<div style="display:flex;align-items:center;gap:6px;min-width:0">
<span style="font-size:10.5px;white-space:nowrap;color:{{ $r['style'] === 'permanent' ? 'rgba(252,165,165,.8)' : 'rgba(252,211,77,.8)' }}">{{ $r['label'] }}</span>
@if($r['remaining'] !== '')
<span style="font-size:10.5px;color:var(--mw-t4);white-space:nowrap">{{ $r['remaining'] }}</span>
@endif
</div>
{{-- Button --}}
<button wire:click="unban('{{ $r['ip'] }}','{{ $r['jail'] }}')"
class="mbx-act-btn mbx-act-danger" title="Entbannen"
wire:confirm="IP {{ $r['ip'] }} aus {{ $r['jail'] }} entbannen?">

View File

@ -16,16 +16,6 @@
</div>
</div>
{{-- Progress --}}
<div class="mb-3">
<div class="h-2 bg-white/10 rounded overflow-hidden">
<div class="h-full bg-emerald-400 transition-all"
style="width: {{ $percent }}%"></div>
</div>
<div class="text-[11px] text-white/50 mt-1">{{ $percent }}%</div>
</div>
{{-- Log Tail --}}
<div class="rounded-lg bg-black/40 border border-white/10 p-2 max-h-64 overflow-auto text-[12px] font-mono text-white/80">
@foreach($tail as $l)

View File

@ -0,0 +1,118 @@
<x-slot:breadcrumb>Mein Profil</x-slot:breadcrumb>
<div style="max-width:720px;margin:0 auto;display:flex;flex-direction:column;gap:16px">
{{-- Profil --}}
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info">
<span class="mbx-badge-mute">Profil</span>
</div>
</div>
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:12px">
<div>
<label class="mw-modal-label">Name</label>
<input type="text" wire:model.defer="name" class="mw-modal-input">
@error('name') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div>
<label class="mw-modal-label">E-Mail</label>
<input type="text" value="{{ $email }}" class="mw-modal-input" disabled style="opacity:.6">
</div>
<div>
<button wire:click="saveProfile" class="mw-btn-primary" style="font-size:12px;padding:5px 14px">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M2 6l3 3 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
Speichern
</button>
</div>
</div>
</div>
{{-- Passwort --}}
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info">
<span class="mbx-badge-mute">Passwort ändern</span>
</div>
</div>
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:12px">
<div>
<label class="mw-modal-label">Aktuelles Passwort</label>
<input type="password" wire:model.defer="current_password" class="mw-modal-input" autocomplete="current-password">
@error('current_password') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div>
<label class="mw-modal-label">Neues Passwort</label>
<input type="password" wire:model.defer="new_password" class="mw-modal-input" autocomplete="new-password">
@error('new_password') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div>
<label class="mw-modal-label">Bestätigung</label>
<input type="password" wire:model.defer="new_password_confirmation" class="mw-modal-input" autocomplete="new-password">
@error('new_password_confirmation') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div>
<button wire:click="changePassword" class="mw-btn-primary" style="font-size:12px;padding:5px 14px">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M2 6l3 3 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
Passwort setzen
</button>
</div>
</div>
</div>
{{-- 2FA --}}
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info">
<span class="mbx-badge-mute">Zwei-Faktor-Authentifizierung</span>
</div>
@if($totpEnabled)
<span class="mbx-badge-ok">Aktiv</span>
@else
<span class="mbx-badge-warn">Nicht eingerichtet</span>
@endif
</div>
<div style="padding:14px 18px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;gap:10px;align-items:flex-start;padding:10px 12px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:7px">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" style="flex-shrink:0;margin-top:1px;color:var(--mw-t4)">
<circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/>
<path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<circle cx="6.5" cy="4" r=".7" fill="currentColor"/>
</svg>
<div style="font-size:11.5px;color:var(--mw-t3);line-height:1.6">
Bei aktiviertem 2FA wird beim Login nach dem Passwort ein
<strong style="color:var(--mw-t2)">6-stelliger TOTP-Code</strong>
aus deiner Authenticator-App verlangt.
</div>
</div>
@if($totpEnabled)
<div style="font-size:12px;color:var(--mw-t3)">
Dein Konto ist mit TOTP geschützt (Google Authenticator, Authy oder kompatible App).
</div>
<div>
<button wire:click="disableTotp"
wire:confirm="2FA wirklich deaktivieren? Dein Konto wird dann nur mit Passwort geschützt."
class="mw-btn-del" style="font-size:12px;padding:5px 12px">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
TOTP deaktivieren
</button>
</div>
@else
<div style="font-size:12px;color:var(--mw-t3)">
Schütze dein Konto mit einem Einmalcode-Generator (TOTP). Einzurichten in unter einer Minute.
</div>
<div>
<button wire:click="$dispatch('openModal',{component:'ui.system.modal.totp-setup-modal'})"
class="mw-btn-primary" style="font-size:12px;padding:5px 14px">
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"><path d="M5.5 1v9M1 5.5h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
TOTP einrichten
</button>
</div>
@endif
</div>
</div>
</div>

View File

@ -12,33 +12,57 @@
Einstellungen
</div>
<div class="mbx-page-actions">
<button wire:click="save" class="mbx-btn-primary">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none"><path d="M2 2h8l2 2v8a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Z" stroke="currentColor" stroke-width="1.2"/><path d="M5 2v3h4V2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
<span wire:loading.remove wire:target="save">Speichern</span>
<span wire:loading wire:target="save">Speichert…</span>
</button>
@if($tab === 'domains')
<button wire:click="saveDomains" wire:loading.attr="disabled" wire:target="saveDomains" class="mbx-btn-primary">
<svg wire:loading.remove wire:target="saveDomains" width="12" height="12" viewBox="0 0 14 14" fill="none"><path d="M2 2h8l2 2v8a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Z" stroke="currentColor" stroke-width="1.2"/><path d="M5 2v3h4V2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
<svg wire:loading wire:target="saveDomains" width="12" height="12" viewBox="0 0 12 12" fill="none" style="animation:spin .7s linear infinite"><circle cx="6" cy="6" r="4.5" stroke="currentColor" stroke-width="1.5" stroke-dasharray="14 7" stroke-linecap="round"/></svg>
<span wire:loading.remove wire:target="saveDomains">Domains speichern</span>
<span wire:loading wire:target="saveDomains">Speichert…</span>
</button>
@else
<button wire:click="save" class="mbx-btn-primary">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none"><path d="M2 2h8l2 2v8a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Z" stroke="currentColor" stroke-width="1.2"/><path d="M5 2v3h4V2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
<span wire:loading.remove wire:target="save">Speichern</span>
<span wire:loading wire:target="save">Speichert…</span>
</button>
@endif
</div>
</div>
<div class="sec-layout">
{{-- Tab-Navigation --}}
<div style="display:flex;gap:2px;border-bottom:1px solid var(--mw-b2);margin-bottom:20px">
@php
$tabs = [
'general' => ['label' => 'Allgemein', 'icon' => '<circle cx="8" cy="8" r="2.2" stroke="currentColor" stroke-width="1.3"/><path d="M8 1.5v1.3M8 13.2v1.3M1.5 8h1.3M13.2 8h1.3M3.2 3.2l.9.9M11.9 11.9l.9.9M3.2 12.8l.9-.9M11.9 4.1l.9-.9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>'],
'domains' => ['label' => 'Domains & SSL','icon' => '<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.3"/><path d="M8 2C6 4.5 5 6.2 5 8s1 3.5 3 6M8 2c2 2.5 3 4.2 3 6s-1 3.5-3 6M2 8h12" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>'],
'backup' => ['label' => 'Sicherung', 'icon' => '<ellipse cx="8" cy="4" rx="5" ry="2" stroke="currentColor" stroke-width="1.3"/><path d="M3 4v4c0 1.1 2.24 2 5 2s5-.9 5-2V4" stroke="currentColor" stroke-width="1.3"/><path d="M3 8v4c0 1.1 2.24 2 5 2s5-.9 5-2V8" stroke="currentColor" stroke-width="1.3"/>'],
'notifications' => ['label' => 'Meldungen', 'icon' => '<path d="M8 14a1.5 1.5 0 0 0 3 0M8 2a4 4 0 0 1 4 4v3l1 1H3l1-1V6a4 4 0 0 1 4-4Z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>'],
];
@endphp
@foreach($tabs as $key => $cfg)
<button wire:click="$set('tab','{{ $key }}')"
style="display:inline-flex;align-items:center;gap:6px;padding:9px 14px;font-size:12.5px;font-weight:500;border:none;background:transparent;cursor:pointer;border-bottom:2px solid {{ $tab === $key ? 'var(--mw-v)' : 'transparent' }};color:{{ $tab === $key ? 'var(--mw-v2)' : 'var(--mw-t4)' }};margin-bottom:-1px;transition:color .15s">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none">{!! $cfg['icon'] !!}</svg>
{{ $cfg['label'] }}
</button>
@endforeach
</div>
{{-- ═══ Main ═══ --}}
{{-- ═══ Tab: Allgemein ═══ --}}
@if($tab === 'general')
<div class="sec-layout">
<div style="min-width:0">
<div class="mbx-sections">
{{-- Allgemein --}}
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info">
<span class="mbx-badge-mute">Allgemein</span>
</div>
<div class="mbx-domain-info"><span class="mbx-badge-mute">Allgemein</span></div>
</div>
<div style="padding:16px 18px">
<div class="mw-modal-grid2">
<div>
<label class="mw-modal-label">Instanzname</label>
<input type="text" value="{{ $instance_name }}" class="mw-modal-input" readonly
style="opacity:.55;cursor:default">
<input type="text" value="{{ $instance_name }}" class="mw-modal-input" readonly style="opacity:.55;cursor:default">
<div class="mw-modal-hint">Wird aus config/app.php gelesen (APP_NAME)</div>
</div>
<div>
@ -67,185 +91,11 @@
</div>
</div>
{{-- 2FA --}}
<livewire:ui.system.two-fa-status />
{{-- Backup --}}
<div class="mbx-section" id="backup">
<div class="mbx-domain-head">
<div class="mbx-domain-info">
<span class="mbx-badge-mute">Backup-Zeitplan</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
@if($backup_enabled)
<span class="mbx-badge-ok">Aktiv</span>
@else
<span class="mbx-badge-warn">Deaktiviert</span>
@endif
<a href="{{ route('ui.system.backups') }}" class="mbx-btn-mute" style="font-size:11.5px;padding:4px 12px;text-decoration:none">Verlauf</a>
</div>
</div>
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:14px">
{{-- Enable toggle --}}
<label class="sec-check">
<input type="checkbox" wire:model.live="backup_enabled">
<span style="font-size:12.5px;color:var(--mw-t2)">Automatisches Backup aktivieren</span>
</label>
{{-- Was sichern --}}
<div>
<label class="mw-modal-label" style="margin-bottom:8px;display:block">Was sichern</label>
<div class="bk-src-grid">
<label class="bk-src-card">
<input type="checkbox" wire:model.live="backup_include_db">
<span class="bk-src-icon">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><ellipse cx="7" cy="3.5" rx="5" ry="2" stroke="currentColor" stroke-width="1.2"/><path d="M2 3.5v7c0 1.1 2.24 2 5 2s5-.9 5-2v-7" stroke="currentColor" stroke-width="1.2"/><path d="M2 7c0 1.1 2.24 2 5 2s5-.9 5-2" stroke="currentColor" stroke-width="1.2"/></svg>
</span>
<span class="bk-src-name">Datenbank</span>
<span class="bk-src-hint">MySQL-Dump</span>
</label>
<label class="bk-src-card">
<input type="checkbox" wire:model.live="backup_include_maildirs">
<span class="bk-src-icon">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M.5 5l6.5 4.5L13.5 5" stroke="currentColor" stroke-width="1.2"/></svg>
</span>
<span class="bk-src-name">E-Mails</span>
<span class="bk-src-hint">Maildirs</span>
</label>
<label class="bk-src-card">
<input type="checkbox" wire:model.live="backup_include_configs">
<span class="bk-src-icon">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 1.5L12.5 4v4.8c0 2.8-2.3 4.8-5.5 5.2C3.8 13.6 1.5 11.6 1.5 8.8V4L7 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</span>
<span class="bk-src-name">Konfiguration</span>
<span class="bk-src-hint">postfix, dovecot, SSL</span>
</label>
</div>
@if($backup_include_maildirs)
<div style="margin-top:8px">
<input type="text" wire:model.defer="backup_mail_dir"
class="mw-modal-input" placeholder="/var/mail/vhosts">
<div class="mw-modal-hint">Maildir-Pfad leer lassen für automatische Erkennung</div>
</div>
@endif
</div>
@if($backup_enabled)
{{-- Preset buttons --}}
<div>
<label class="mw-modal-label">Häufigkeit</label>
<div style="display:flex;gap:6px;flex-wrap:wrap">
@foreach(['hourly' => 'Stündlich', 'daily' => 'Täglich', 'weekly' => 'Wöchentlich', 'monthly' => 'Monatlich', 'custom' => 'Benutzerdefiniert'] as $val => $label)
<button type="button" wire:click="$set('backup_preset','{{ $val }}')"
style="padding:5px 14px;border-radius:6px;font-size:12px;cursor:pointer;transition:all .1s;
{{ $backup_preset === $val
? 'background:var(--mw-vbg);border:1px solid var(--mw-vbd);color:var(--mw-v2);font-weight:600'
: 'background:transparent;border:1px solid var(--mw-b2);color:var(--mw-t4)' }}">
{{ $label }}
</button>
@endforeach
</div>
</div>
{{-- Time / day pickers --}}
@if($backup_preset !== 'hourly' && $backup_preset !== 'custom')
<div class="mw-modal-grid2">
<div>
<label class="mw-modal-label">Uhrzeit</label>
<input type="time" wire:model.live="backup_time" class="mw-modal-input">
</div>
@if($backup_preset === 'weekly')
<div>
<label class="mw-modal-label">Wochentag</label>
<select wire:model.live="backup_weekday" class="mw-modal-input">
@foreach(['0'=>'Sonntag','1'=>'Montag','2'=>'Dienstag','3'=>'Mittwoch','4'=>'Donnerstag','5'=>'Freitag','6'=>'Samstag'] as $v => $d)
<option value="{{ $v }}">{{ $d }}</option>
@endforeach
</select>
</div>
@elseif($backup_preset === 'monthly')
<div>
<label class="mw-modal-label">Tag des Monats</label>
<select wire:model.live="backup_monthday" class="mw-modal-input">
@for($d = 1; $d <= 28; $d++)
<option value="{{ $d }}">{{ $d }}.</option>
@endfor
</select>
</div>
@endif
</div>
@endif
@if($backup_preset === 'custom')
<div>
<label class="mw-modal-label">Cron-Ausdruck</label>
<input type="text" wire:model.live="backup_cron" class="mw-modal-input"
placeholder="0 3 * * *" style="font-family:monospace">
<div class="mw-modal-hint">Standard Cron-Syntax: Minute Stunde Tag Monat Wochentag</div>
</div>
@endif
{{-- Cron-Vorschau --}}
<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:7px">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><circle cx="6" cy="6" r="5" stroke="var(--mw-t4)" stroke-width="1.2"/><path d="M6 3.5v3l1.5 1.5" stroke="var(--mw-t4)" stroke-width="1.2" stroke-linecap="round"/></svg>
<span style="font-size:11px;color:var(--mw-t4)">Cron:</span>
<code style="font-family:monospace;font-size:11px;color:var(--mw-v2)">{{ $backup_cron }}</code>
</div>
{{-- Aufbewahrung --}}
<div>
<label class="mw-modal-label">Aufbewahrung (Anzahl Backups)</label>
<input type="number" wire:model.defer="backup_retention" class="mw-modal-input"
min="1" max="365" style="max-width:120px">
<div class="mw-modal-hint">Ältere Backups werden automatisch gelöscht. Empfehlung: 714.</div>
</div>
@endif
</div>
</div>
{{-- Benachrichtigungen --}}
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info">
<span class="mbx-badge-mute">Benachrichtigungen</span>
</div>
</div>
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:14px">
<div class="mw-modal-grid2">
<div>
<label class="mw-modal-label">Absendername</label>
<input type="text" wire:model.defer="notify_sender_name" class="mw-modal-input" placeholder="CluBird Benachrichtigung">
<div class="mw-modal-hint">Wird als Absender in System-Mails angezeigt</div>
</div>
<div>
<label class="mw-modal-label">Admin-E-Mail</label>
<input type="email" wire:model.defer="notify_admin_email" class="mw-modal-input" placeholder="admin@example.com">
<div class="mw-modal-hint">Empfänger für Update- & Systemmeldungen</div>
</div>
</div>
@php
$sysmailFqdn = \App\Models\Setting::get('notify_from_address')
?: ('no-reply@' . config('mailpool.platform_system_zone','sysmail') . '.' . config('mailpool.platform_zone',''));
@endphp
<div style="display:flex;align-items:center;gap:8px;padding:9px 12px;border-radius:7px;background:var(--mw-bg3);border:1px solid var(--mw-b2)">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:var(--mw-t4)"><rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M.5 5l6.5 4 6.5-4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
<span style="font-size:11px;color:var(--mw-t4)">Absenderadresse:</span>
<span style="font-family:monospace;font-size:11px;color:var(--mw-t2)">{{ $sysmailFqdn }}</span>
</div>
</div>
</div>
{{-- Sicherheit --}}
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info">
<span class="mbx-badge-mute">Sicherheit</span>
</div>
<div class="mbx-domain-info"><span class="mbx-badge-mute">Sicherheit</span></div>
</div>
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:14px">
<div class="mw-modal-grid2">
@ -266,16 +116,48 @@
</div>
</div>
{{-- ═══ Sidebar ═══ --}}
<div>
<div class="mbx-sections">
{{-- Domains --}}
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info">
<span class="mbx-badge-mute">Domains</span>
<div class="mbx-domain-info"><span class="mbx-badge-mute">System-Info</span></div>
</div>
<div style="padding:14px 16px;font-size:11.5px;color:var(--mw-t3);display:flex;flex-direction:column;gap:8px">
<div style="display:flex;justify-content:space-between">
<span style="color:var(--mw-t4)">PHP</span>
<span>{{ PHP_VERSION }}</span>
</div>
<div style="display:flex;justify-content:space-between">
<span style="color:var(--mw-t4)">Laravel</span>
<span>{{ app()->version() }}</span>
</div>
<div style="display:flex;justify-content:space-between">
<span style="color:var(--mw-t4)">Umgebung</span>
<span>{{ app()->environment() }}</span>
</div>
<div style="display:flex;justify-content:space-between">
<span style="color:var(--mw-t4)">Hostname</span>
<span style="font-family:monospace;font-size:10.5px">{{ gethostname() }}</span>
</div>
<div style="display:flex;justify-content:space-between">
<span style="color:var(--mw-t4)">Certbot</span>
<span>{{ trim(@shell_exec('certbot --version 2>&1') ?? '—') ?: '—' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
@endif
{{-- ═══ Tab: Domains & SSL ═══ --}}
@if($tab === 'domains')
<div class="sec-layout">
<div style="min-width:0">
<div class="mbx-sections">
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info"><span class="mbx-badge-mute">Domains</span></div>
</div>
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:12px">
@ -292,40 +174,30 @@
<div>
<label class="mw-modal-label">UI Domain</label>
<input type="text" wire:model="ui_domain" class="mw-modal-input"
placeholder="mail.example.com">
<input type="text" wire:model="ui_domain" class="mw-modal-input" placeholder="mail.example.com">
@error('ui_domain') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div>
<label class="mw-modal-label">Mailserver Domain</label>
<input type="text" wire:model="mail_domain" class="mw-modal-input"
placeholder="mx.example.com">
<input type="text" wire:model="mail_domain" class="mw-modal-input" placeholder="mx.example.com">
@error('mail_domain') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div>
<label class="mw-modal-label">Webmail Domain</label>
<input type="text" wire:model="webmail_domain" class="mw-modal-input"
placeholder="webmail.example.com">
<input type="text" wire:model="webmail_domain" class="mw-modal-input" placeholder="webmail.example.com">
@error('webmail_domain') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<button wire:click="saveDomains"
wire:loading.attr="disabled"
wire:target="saveDomains"
class="mbx-btn-primary" style="width:100%;justify-content:center;font-size:12px">
<span wire:loading.remove wire:target="saveDomains" style="display:inline-flex;align-items:center;gap:5px">
<svg width="11" height="11" viewBox="0 0 14 14" fill="none"><path d="M2 2h8l2 2v8a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Z" stroke="currentColor" stroke-width="1.2"/><path d="M5 2v3h4V2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>Domains speichern
</span>
<span wire:loading wire:target="saveDomains">Speichert…</span>
</button>
</div>
</div>
</div>
</div>
{{-- SSL-Zertifikate --}}
<div>
<div class="mbx-sections">
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info">
<span class="mbx-badge-mute">SSL-Zertifikate</span>
</div>
<div class="mbx-domain-info"><span class="mbx-badge-mute">SSL-Zertifikate</span></div>
</div>
<div style="padding:10px 14px;display:flex;flex-direction:column;gap:5px">
@foreach(['ui','webmail','mail'] as $key)
@ -351,41 +223,244 @@
</a>
</div>
</div>
</div>
</div>
</div>
@endif
{{-- Info --}}
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info">
<span class="mbx-badge-mute">System-Info</span>
</div>
{{-- ═══ Tab: Sicherung ═══ --}}
@if($tab === 'backup')
<div class="sec-layout">
<div style="min-width:0"><div class="mbx-sections">
<div class="mbx-section" id="backup">
<div class="mbx-domain-head">
<div class="mbx-domain-info"><span class="mbx-badge-mute">Backup-Zeitplan</span></div>
<div style="display:flex;align-items:center;gap:8px">
@if($backup_enabled)
<span class="mbx-badge-ok">Aktiv</span>
@else
<span class="mbx-badge-warn">Deaktiviert</span>
@endif
<a href="{{ route('ui.system.backups') }}" class="mbx-btn-mute" style="font-size:11.5px;padding:4px 12px;text-decoration:none">Verlauf</a>
</div>
</div>
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:14px">
<label class="sec-check">
<input type="checkbox" wire:model.live="backup_enabled">
<span style="font-size:12.5px;color:var(--mw-t2)">Automatisches Backup aktivieren</span>
</label>
<div>
<label class="mw-modal-label" style="margin-bottom:8px;display:block">Was sichern</label>
<div class="bk-src-grid">
<label class="bk-src-card">
<input type="checkbox" wire:model.live="backup_include_db">
<span class="bk-src-icon">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><ellipse cx="7" cy="3.5" rx="5" ry="2" stroke="currentColor" stroke-width="1.2"/><path d="M2 3.5v7c0 1.1 2.24 2 5 2s5-.9 5-2v-7" stroke="currentColor" stroke-width="1.2"/><path d="M2 7c0 1.1 2.24 2 5 2s5-.9 5-2" stroke="currentColor" stroke-width="1.2"/></svg>
</span>
<span class="bk-src-name">Datenbank</span>
<span class="bk-src-hint">MySQL-Dump</span>
</label>
<label class="bk-src-card">
<input type="checkbox" wire:model.live="backup_include_maildirs">
<span class="bk-src-icon">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M.5 5l6.5 4.5L13.5 5" stroke="currentColor" stroke-width="1.2"/></svg>
</span>
<span class="bk-src-name">E-Mails</span>
<span class="bk-src-hint">Maildirs</span>
</label>
<label class="bk-src-card">
<input type="checkbox" wire:model.live="backup_include_configs">
<span class="bk-src-icon">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 1.5L12.5 4v4.8c0 2.8-2.3 4.8-5.5 5.2C3.8 13.6 1.5 11.6 1.5 8.8V4L7 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</span>
<span class="bk-src-name">Konfiguration</span>
<span class="bk-src-hint">postfix, dovecot, SSL</span>
</label>
</div>
<div style="padding:14px 16px;font-size:11.5px;color:var(--mw-t3);display:flex;flex-direction:column;gap:8px">
<div style="display:flex;justify-content:space-between">
<span style="color:var(--mw-t4)">PHP</span>
<span>{{ PHP_VERSION }}</span>
</div>
<div style="display:flex;justify-content:space-between">
<span style="color:var(--mw-t4)">Laravel</span>
<span>{{ app()->version() }}</span>
</div>
<div style="display:flex;justify-content:space-between">
<span style="color:var(--mw-t4)">Umgebung</span>
<span>{{ app()->environment() }}</span>
</div>
<div style="display:flex;justify-content:space-between">
<span style="color:var(--mw-t4)">Hostname</span>
<span style="font-family:monospace;font-size:10.5px">{{ gethostname() }}</span>
</div>
<div style="display:flex;justify-content:space-between">
<span style="color:var(--mw-t4)">Certbot</span>
<span>{{ trim(@shell_exec('certbot --version 2>&1') ?? '—') ?: '—' }}</span>
</div>
@if($backup_include_maildirs)
<div style="margin-top:8px">
<input type="text" wire:model.defer="backup_mail_dir" class="mw-modal-input" placeholder="/var/mail/vhosts">
<div class="mw-modal-hint">Maildir-Pfad leer lassen für automatische Erkennung</div>
</div>
@endif
</div>
@if($backup_enabled)
<div>
<label class="mw-modal-label">Häufigkeit</label>
<div style="display:flex;gap:6px;flex-wrap:wrap">
@foreach(['hourly' => 'Stündlich', 'daily' => 'Täglich', 'weekly' => 'Wöchentlich', 'monthly' => 'Monatlich', 'custom' => 'Benutzerdefiniert'] as $val => $label)
<button type="button" wire:click="$set('backup_preset','{{ $val }}')"
style="padding:5px 14px;border-radius:6px;font-size:12px;cursor:pointer;transition:all .1s;
{{ $backup_preset === $val
? 'background:var(--mw-vbg);border:1px solid var(--mw-vbd);color:var(--mw-v2);font-weight:600'
: 'background:transparent;border:1px solid var(--mw-b2);color:var(--mw-t4)' }}">
{{ $label }}
</button>
@endforeach
</div>
</div>
@if($backup_preset !== 'hourly' && $backup_preset !== 'custom')
<div class="mw-modal-grid2">
<div>
<label class="mw-modal-label">Uhrzeit</label>
<input type="time" wire:model.live="backup_time" class="mw-modal-input">
</div>
@if($backup_preset === 'weekly')
<div>
<label class="mw-modal-label">Wochentag</label>
<select wire:model.live="backup_weekday" class="mw-modal-input">
@foreach(['0'=>'Sonntag','1'=>'Montag','2'=>'Dienstag','3'=>'Mittwoch','4'=>'Donnerstag','5'=>'Freitag','6'=>'Samstag'] as $v => $d)
<option value="{{ $v }}">{{ $d }}</option>
@endforeach
</select>
</div>
@elseif($backup_preset === 'monthly')
<div>
<label class="mw-modal-label">Tag des Monats</label>
<select wire:model.live="backup_monthday" class="mw-modal-input">
@for($d = 1; $d <= 28; $d++)
<option value="{{ $d }}">{{ $d }}.</option>
@endfor
</select>
</div>
@endif
</div>
@endif
@if($backup_preset === 'custom')
<div>
<label class="mw-modal-label">Cron-Ausdruck</label>
<input type="text" wire:model.live="backup_cron" class="mw-modal-input" placeholder="0 3 * * *" style="font-family:monospace">
<div class="mw-modal-hint">Standard Cron-Syntax: Minute Stunde Tag Monat Wochentag</div>
</div>
@endif
<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:7px">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><circle cx="6" cy="6" r="5" stroke="var(--mw-t4)" stroke-width="1.2"/><path d="M6 3.5v3l1.5 1.5" stroke="var(--mw-t4)" stroke-width="1.2" stroke-linecap="round"/></svg>
<span style="font-size:11px;color:var(--mw-t4)">Cron:</span>
<code style="font-family:monospace;font-size:11px;color:var(--mw-v2)">{{ $backup_cron }}</code>
</div>
<div>
<label class="mw-modal-label">Aufbewahrung (Anzahl Backups)</label>
<input type="number" wire:model.defer="backup_retention" class="mw-modal-input" min="1" max="365" style="max-width:120px">
<div class="mw-modal-hint">Ältere Backups werden automatisch gelöscht. Empfehlung: 714.</div>
</div>
@endif
</div>
</div>
</div></div>
{{-- Sidebar: Sicherung --}}
<div><div class="mbx-sections">
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info"><span class="mbx-badge-mute">Letztes Backup</span></div>
</div>
<div style="padding:12px 14px;display:flex;flex-direction:column;gap:8px;font-size:11.5px">
@if($backupMeta && $backupMeta->last_run_at)
@php
$statusColor = $backupMeta->last_status === 'ok' ? '#22c55e' : '#f87171';
$statusLabel = $backupMeta->last_status === 'ok' ? 'Erfolgreich' : 'Fehler';
$sizeKb = $backupMeta->last_size_bytes ? round($backupMeta->last_size_bytes / 1024) : null;
@endphp
<div style="display:flex;justify-content:space-between">
<span style="color:var(--mw-t4)">Status</span>
<span style="color:{{ $statusColor }}">{{ $statusLabel }}</span>
</div>
<div style="display:flex;justify-content:space-between">
<span style="color:var(--mw-t4)">Zuletzt</span>
<span style="color:var(--mw-t2)">{{ \Carbon\Carbon::parse($backupMeta->last_run_at)->format('d.m.Y H:i') }}</span>
</div>
@if($sizeKb)
<div style="display:flex;justify-content:space-between">
<span style="color:var(--mw-t4)">Größe</span>
<span style="color:var(--mw-t2)">{{ $sizeKb < 1024 ? $sizeKb.' KB' : round($sizeKb/1024,1).' MB' }}</span>
</div>
@endif
<div style="display:flex;justify-content:space-between">
<span style="color:var(--mw-t4)">Aufbewahrung</span>
<span style="color:var(--mw-t2)">{{ $backupMeta->retention_count }} Stück</span>
</div>
@else
<span style="color:var(--mw-t4)">Noch kein Backup ausgeführt.</span>
@endif
<a href="{{ route('ui.system.backups') }}" style="display:flex;align-items:center;gap:5px;font-size:11px;color:var(--mw-v);padding:4px 0;text-decoration:none;margin-top:2px">
<svg width="10" height="10" viewBox="0 0 14 14" fill="none"><ellipse cx="7" cy="3.5" rx="5" ry="2" stroke="currentColor" stroke-width="1.3"/><path d="M2 3.5v7c0 1.1 2.24 2 5 2s5-.9 5-2v-7" stroke="currentColor" stroke-width="1.3"/></svg>
Backup-Verlauf
</a>
</div>
</div>
</div></div>
</div>
@endif
{{-- ═══ Tab: Meldungen ═══ --}}
@if($tab === 'notifications')
<div class="sec-layout">
<div style="min-width:0"><div class="mbx-sections">
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info"><span class="mbx-badge-mute">Benachrichtigungen</span></div>
</div>
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:14px">
<div class="mw-modal-grid2">
<div>
<label class="mw-modal-label">Absendername</label>
<input type="text" wire:model.defer="notify_sender_name" class="mw-modal-input" placeholder="CluBird Benachrichtigung">
<div class="mw-modal-hint">Wird als Absender in System-Mails angezeigt</div>
</div>
<div>
<label class="mw-modal-label">Admin-E-Mail</label>
<input type="email" wire:model.defer="notify_admin_email" class="mw-modal-input" placeholder="admin@example.com">
<div class="mw-modal-hint">Empfänger für Update- & Systemmeldungen</div>
</div>
</div>
@php
$sysmailFqdn = \App\Models\Setting::get('notify_from_address')
?: ('no-reply@' . config('mailpool.platform_system_zone','sysmail') . '.' . config('mailpool.platform_zone',''));
@endphp
<div style="display:flex;align-items:center;gap:8px;padding:9px 12px;border-radius:7px;background:var(--mw-bg3);border:1px solid var(--mw-b2)">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:var(--mw-t4)"><rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M.5 5l6.5 4 6.5-4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
<span style="font-size:11px;color:var(--mw-t4)">Absenderadresse:</span>
<span style="font-family:monospace;font-size:11px;color:var(--mw-t2)">{{ $sysmailFqdn }}</span>
</div>
</div>
</div>
</div></div>
{{-- Sidebar: Meldungen --}}
<div><div class="mbx-sections">
<div class="mbx-section">
<div class="mbx-domain-head">
<div class="mbx-domain-info"><span class="mbx-badge-mute">Meldungstypen</span></div>
</div>
<div style="padding:12px 14px;display:flex;flex-direction:column;gap:6px;font-size:11.5px">
@foreach([
['Update verfügbar', 'Neue App-Version erkannt'],
['Update abgeschlossen', 'Nach erfolgreichem Update'],
['Backup-Fehler', 'Wenn ein Backup fehlschlägt'],
['SSL läuft ab', 'Zertifikat < 14 Tage gültig'],
] as [$title, $desc])
<div style="display:flex;align-items:flex-start;gap:8px;padding:7px 10px;border-radius:6px;background:var(--mw-bg3);border:1px solid var(--mw-b2)">
<svg width="10" height="10" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;margin-top:2px;color:var(--mw-v)"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.3"/><path d="M7 4.5v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="7" cy="9.5" r=".6" fill="currentColor"/></svg>
<div>
<div style="color:var(--mw-t2);font-weight:500">{{ $title }}</div>
<div style="color:var(--mw-t4);font-size:11px;margin-top:1px">{{ $desc }}</div>
</div>
</div>
@endforeach
</div>
</div>
</div></div>
</div>
@endif
</div>

View File

@ -68,6 +68,14 @@
{{ $buttonLabel }}
</button>
@endif
<button wire:click="openLogs"
class="inline-flex items-center gap-1 rounded-full text-[10px] px-2.5 py-0.5 border
border-white/10 bg-white/5 hover:bg-white/10 transition-colors"
style="color:var(--mw-t4)">
<i class="ph ph-list-bullets text-[11px]"></i>
Logs
</button>
</div>
</div>

View File

@ -29,7 +29,7 @@
{{-- </div>--}}
{{-- <div>--}}
{{-- <h3 class="text-lg font-semibold text-white/90">WoltGuard</h3>--}}
{{-- <h3 class="text-lg font-semibold text-white/90">CluGuard</h3>--}}
{{-- <p class="text-xs text-white/50">System-Wächter</p>--}}
{{-- </div>--}}
{{-- </div>--}}
@ -90,7 +90,7 @@
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-white/90">WoltGuard</h3>
<h3 class="text-lg font-semibold text-white/90">CluGuard</h3>
<p class="text-xs text-white/50">System-Wächter</p>
<p class="text-[11px] text-white/40 mt-1">
{{ $okCount }}/{{ $totalCount }} Dienste aktiv

View File

@ -121,7 +121,7 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 1.5L12.5 4v4.5c0 2.8-2.3 4.5-5.5 5C3.8 13 1.5 11.3 1.5 8.5V4L7 1.5Z" stroke="#22c55e" stroke-width="1.2" stroke-linejoin="round"/><path d="M4.5 7l2 2 3-3" stroke="#22c55e" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div>
<div class="mw-sc-title">WoltGuard</div>
<div class="mw-sc-title">CluGuard</div>
<div class="mw-sc-sub">System-Wächter</div>
</div>
<span class="mw-sc-badge mw-badge-ok">alle Dienste OK</span>

View File

@ -12,6 +12,8 @@ use App\Http\Controllers\UI\Security\SecurityController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
Route::get('/ping', fn() => response('ok', 200))->name('ping');
Route::get('/', function () {
try {
$setupDone = \App\Models\Setting::get('setup_completed', '0') === '1';
@ -95,6 +97,9 @@ Route::middleware(['auth.user', 'require2fa'])->name('ui.')->group(function () {
// });
// });
#PROFILE ROUTE
Route::get('/profile', \App\Livewire\Ui\System\ProfilePage::class)->name('profile');
#LOGOUT ROUTE
Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
});

View File

@ -10,8 +10,8 @@ use Illuminate\Support\Facades\Route;
// Domain-wrapping and naming are applied in bootstrap/app.php.
// This file only defines the routes themselves.
// Root → redirect to UI admin login; webmail login is at /login
Route::get('/', fn() => redirect()->away(config('app.url') . '/login'))->name('root');
// Root → redirect to webmail login
Route::get('/', fn() => redirect()->to(route('ui.webmail.login')))->name('root');
Route::get('/login', Login::class)->name('login');
Route::get('/inbox', Inbox::class)->name('inbox');
Route::get('/compose', Compose::class)->name('compose');

View File

@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Read ban timing from fail2ban SQLite — called via sudo by www-data."""
import sqlite3, sys, os
if len(sys.argv) < 3:
sys.exit(1)
jail = sys.argv[1]
ip = sys.argv[2]
db = '/var/lib/fail2ban/fail2ban.sqlite3'
if not os.path.isfile(db):
sys.exit(1)
try:
conn = sqlite3.connect(f'file:{db}?mode=ro', uri=True)
cur = conn.execute(
'SELECT timeofban, bantime FROM bans WHERE jail=? AND ip=? ORDER BY timeofban DESC LIMIT 1',
(jail, ip)
)
row = cur.fetchone()
conn.close()
if row:
print(f'{row[0]}|{row[1]}')
except Exception:
sys.exit(1)

View File

@ -270,11 +270,36 @@ if [ -d "${STATE_DIR}" ]; then
fi
# --- Phase 4: Postfix + Dovecot mit Mail-Domain-Zertifikat konfigurieren ---
if [ -n "${MAIL_HOST}" ]; then
# Hostname immer setzen, unabhängig vom Zertifikat
if command -v postconf >/dev/null 2>&1; then
postconf -e "myhostname = ${MAIL_HOST}"
postconf -e "myorigin = ${MAIL_HOST}"
postconf -e "mydestination = ${MAIL_HOST}, localhost.localdomain, localhost"
fi
# /etc/aliases bereinigen: root → /dev/null verhindert Bounce-Schleife
if [ -f /etc/aliases ]; then
# Vorhandene root/clamav-Zeilen ersetzen oder am Ende ergänzen
if grep -q '^root:' /etc/aliases; then
sed -i 's|^root:.*|root: /dev/null|' /etc/aliases
else
echo 'root: /dev/null' >> /etc/aliases
fi
if grep -q '^clamav:' /etc/aliases; then
sed -i 's|^clamav:.*|clamav: /dev/null|' /etc/aliases
else
echo 'clamav: /dev/null' >> /etc/aliases
fi
command -v newaliases >/dev/null 2>&1 && newaliases
fi
fi
if [ -n "${MAIL_HOST}" ] && [ "${MAIL_HAS_CERT}" = "1" ]; then
MAIL_CERT="/etc/letsencrypt/live/${MAIL_HOST}/fullchain.pem"
MAIL_KEY="/etc/letsencrypt/live/${MAIL_HOST}/privkey.pem"
# Postfix
# Postfix TLS
if command -v postconf >/dev/null 2>&1; then
postconf -e "smtpd_tls_cert_file = ${MAIL_CERT}"
postconf -e "smtpd_tls_key_file = ${MAIL_KEY}"

View File

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Setzt Postfix myhostname/myorigin und bereinigt /etc/aliases.
# Wird von Mailwolt aufgerufen wenn die Mail-Domain gespeichert wird.
set -euo pipefail
MAIL_HOST="${1:-}"
if [ -z "${MAIL_HOST}" ]; then
echo "Usage: mailwolt-apply-hostname <mail-host>" >&2
exit 1
fi
# Nur sichere Hostnamen zulassen
if ! echo "${MAIL_HOST}" | grep -qE '^[a-zA-Z0-9][a-zA-Z0-9.\-]+$'; then
echo "Ungültiger Hostname: ${MAIL_HOST}" >&2
exit 1
fi
if command -v postconf >/dev/null 2>&1; then
postconf -e "myhostname = ${MAIL_HOST}"
postconf -e "myorigin = ${MAIL_HOST}"
postconf -e "mydestination = ${MAIL_HOST}, localhost.localdomain, localhost"
systemctl reload postfix 2>/dev/null || true
fi
if [ -f /etc/aliases ]; then
if grep -q '^root:' /etc/aliases; then
sed -i 's|^root:.*|root: /dev/null|' /etc/aliases
else
echo 'root: /dev/null' >> /etc/aliases
fi
if grep -q '^clamav:' /etc/aliases; then
sed -i 's|^clamav:.*|clamav: /dev/null|' /etc/aliases
else
echo 'clamav: /dev/null' >> /etc/aliases
fi
command -v newaliases >/dev/null 2>&1 && newaliases
fi
echo "mailwolt-apply-hostname fertig"

View File

@ -6,8 +6,8 @@ APP_DIR="/var/www/mailwolt"
APP_USER="www-data"
OUT_DIR="${APP_DIR}/storage/app/backups"
STAMP="$(date +%Y-%m-%d_%H-%M-%S)"
OUT_FILE="${OUT_DIR}/mailwolt_${STAMP}.tar.gz"
TMP_DIR="$(mktemp -d /tmp/mailwolt_backup_XXXXXX)"
OUT_FILE="${OUT_DIR}/clubird_${STAMP}.tar.gz"
TMP_DIR="$(mktemp -d /tmp/clubird_backup_XXXXXX)"
cleanup(){ rm -rf "$TMP_DIR"; }
trap cleanup EXIT

View File

@ -202,9 +202,10 @@ ensure_system(){
fix_permissions
# Scripts nach sbin
_sbin_install "${APP_DIR}/scripts/mailwolt-apply-domains" /usr/local/sbin/mailwolt-apply-domains
_sbin_install "${APP_DIR}/scripts/mailwolt-fetch-tags" /usr/local/sbin/mailwolt-fetch-tags
_sbin_install "${APP_DIR}/scripts/update.sh" /usr/local/sbin/mailwolt-update
_sbin_install "${APP_DIR}/scripts/mailwolt-apply-domains" /usr/local/sbin/mailwolt-apply-domains
_sbin_install "${APP_DIR}/scripts/mailwolt-apply-hostname" /usr/local/sbin/mailwolt-apply-hostname
_sbin_install "${APP_DIR}/scripts/mailwolt-fetch-tags" /usr/local/sbin/mailwolt-fetch-tags
_sbin_install "${APP_DIR}/scripts/update.sh" /usr/local/sbin/mailwolt-update
_sbin_install "${APP_DIR}/scripts/mailwolt-clamav" /usr/local/sbin/mailwolt-clamav
_sbin_install "${APP_DIR}/scripts/mailwolt-backup" /usr/local/sbin/mailwolt-backup
_sbin_install "${APP_DIR}/scripts/mailwolt-sandbox-sync" /usr/local/sbin/mailwolt-sandbox-sync
@ -216,12 +217,22 @@ ensure_system(){
_sudoers_add "$MW_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-clamav'
_sudoers_add "$MW_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-backup'
_sudoers_add "$MW_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-sandbox-sync *'
_sudoers_add "$MW_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-apply-hostname *'
_sudoers_add "$MW_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/bin/openssl x509 -enddate -noout -in /etc/letsencrypt/live/*/fullchain.pem'
# Legacy-Files ebenfalls nachrüsten (falls vorhanden)
for f in /etc/sudoers.d/mailwolt-certbot /etc/sudoers.d/mailwolt-dkim; do
[[ -f "$f" ]] || continue
_sudoers_add "$f" 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-backup'
done
# RSpamd: Loopback als lokale Adresse eintragen (verhindert 127.0.0.1-Einträge in History)
local rspamd_opts="/etc/rspamd/local.d/options.inc"
if [[ -d /etc/rspamd/local.d ]] && ! grep -q "127.0.0.1" "${rspamd_opts}" 2>/dev/null; then
printf 'local_addrs = [127.0.0.1, ::1, 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, fd00::/8, 169.254.0.0/16, fe80::/10];\n' \
> "${rspamd_opts}"
systemctl is-active --quiet rspamd && systemctl reload rspamd || true
echo "[✓] RSpamd: local_addrs mit Loopback gesetzt."
fi
# Laravel Scheduler cron (schedule:run muss jede Minute laufen)
local cron_file="/etc/cron.d/mailwolt"
local cron_line="* * * * * www-data php ${APP_DIR}/artisan schedule:run >> /dev/null 2>&1"
@ -282,6 +293,82 @@ UNIT
systemctl stop clamav-daemon 2>/dev/null || true
echo "[✓] ClamAV installiert (deaktiviert User kann es einschalten)."
fi
# Fail2Ban: Jails + Sudoers sicherstellen (idempotent)
local F2B_SUDO="/etc/sudoers.d/clubird-fail2ban"
local F2B_JAIL="/etc/fail2ban/jail.d/clubird-jails.local"
_sbin_install "${APP_DIR}/scripts/clubird-f2b-baninfo" /usr/local/sbin/clubird-f2b-baninfo
if command -v fail2ban-client >/dev/null 2>&1; then
# Sudoers idempotent befüllen (fehlende Einträge werden nachgetragen)
if [[ ! -f "$F2B_SUDO" ]]; then
touch "$F2B_SUDO"; chmod 440 "$F2B_SUDO"
fi
_sudoers_add "$F2B_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/fail2ban/jail.d/00-defaults.local'
_sudoers_add "$F2B_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/fail2ban/jail.d/whitelist.local'
_sudoers_add "$F2B_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client reload'
_sudoers_add "$F2B_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client status'
_sudoers_add "$F2B_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client status *'
_sudoers_add "$F2B_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client set * banip *'
_sudoers_add "$F2B_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client set * unbanip *'
_sudoers_add "$F2B_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client get *'
_sudoers_add "$F2B_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/clubird-f2b-baninfo *'
# Jail-Konfiguration (dovecot, postfix, nginx)
if [[ ! -f "$F2B_JAIL" ]]; then
cat > "$F2B_JAIL" << 'JAILEOF'
[dovecot]
enabled = true
port = pop3,pop3s,imap,imaps,submission,submissions,sieve
filter = dovecot
logpath = /var/log/mail.log
maxretry = 5
[postfix]
enabled = true
port = smtp,465,submission,submissions
filter = postfix[mode=more]
logpath = /var/log/mail.log
maxretry = 5
[nginx-http-auth]
enabled = true
port = http,https
filter = nginx-http-auth
logpath = /var/log/nginx/error.log
/var/log/nginx/mailwolt_error.log
/var/log/nginx/mailwolt_ssl_error.log
maxretry = 5
[nginx-botsearch]
enabled = true
port = http,https
filter = nginx-botsearch
logpath = /var/log/nginx/access.log
/var/log/nginx/mailwolt_access.log
/var/log/nginx/mailwolt_ssl_access.log
maxretry = 2
JAILEOF
systemctl is-active --quiet fail2ban && fail2ban-client reload >/dev/null 2>&1 || true
echo "[✓] Fail2Ban Jails installiert (dovecot, postfix, nginx)."
fi
fi
# Postfix: myhostname + $myhostname in mydestination eintragen damit System-Mails
# (cron, PHP mail()) nicht als Relay-Versuch gewertet werden
local POSTCONF=/usr/sbin/postconf
local POSTFIX=/usr/sbin/postfix
if [[ -x "$POSTCONF" ]]; then
local pf_host
pf_host=$("$POSTCONF" -h myhostname 2>/dev/null || true)
local current_dest
current_dest=$("$POSTCONF" -h mydestination 2>/dev/null || true)
local need_update=0
[[ -n "$pf_host" ]] && ! echo "$current_dest" | grep -qF "$pf_host" && need_update=1
if [[ "$need_update" -eq 1 ]]; then
"$POSTCONF" -e "mydestination = $pf_host, \$myhostname, localhost.localdomain, localhost"
[[ -x "$POSTFIX" ]] && "$POSTFIX" reload >/dev/null 2>&1 || true
echo "[✓] Postfix: $pf_host zu mydestination hinzugefügt (System-Mails lokal zugestellt)."
fi
fi
}
# -------- Guards --------------------------------------------------------------
@ -311,7 +398,10 @@ _cleanup() {
sudo -u "${APP_USER}" php "${APP_DIR}/artisan" optimize:clear 2>&1 || true
sudo -u "${APP_USER}" php "${APP_DIR}/artisan" optimize 2>&1 || true
fi
# [DONE] direkt ins Log-File schreiben (am tee-Pipe vorbei) damit
# die Reihenfolge garantiert ist: Log hat [DONE] bevor state=done sichtbar ist
mkdir -p "$STATE_DIR"
printf '[DONE]\n' >> "$LOG_FILE" 2>/dev/null || true
echo "done" > "$STATE_DIR/state"
echo "$rc" > "$STATE_DIR/rc"
rm -f "$LOCK_FILE"
@ -472,11 +562,6 @@ sudo -u "${APP_USER}" php "${APP_DIR}/artisan" optimize:clear 2>&1 || true
sudo -u "${APP_USER}" php "${APP_DIR}/artisan" optimize 2>&1 || true
echo "[✓] Cache aufgebaut."
# -------- Wartungsmodus beenden ----------------------------------------------
artisan_up
MAINTENANCE_ACTIVE=0
echo "[✓] Update abgeschlossen: ${OLD_VER}${NEW_VER} (${OLD_REV:0:7}${NEW_REV:0:7})"
# -------- .env: REVERB-Werte auf Domain-Basis normalisieren -------------------
migrate_env_reverb() {
local env_file="${APP_DIR}/.env"
@ -536,4 +621,9 @@ migrate_env_reverb() {
}
migrate_env_reverb
ensure_system
ensure_system
# -------- Wartungsmodus beenden (als letzter Schritt — erst wenn alles fertig) ---
artisan_up
MAINTENANCE_ACTIVE=0
echo "[✓] Update abgeschlossen: ${OLD_VER}${NEW_VER} (${OLD_REV:0:7}${NEW_REV:0:7})"