'required|string|max:10', 'timezone' => 'required|string|max:64', 'session_timeout' => 'required|integer|min:5|max:1440', 'notify_sender_name' => 'nullable|string|max:80', 'notify_admin_email' => 'nullable|email|max:190', 'rate_limit' => 'required|integer|min:1|max:100', 'password_min' => 'required|integer|min:6|max:128', 'backup_enabled' => 'boolean', 'backup_preset' => 'required|in:hourly,daily,weekly,monthly,custom', 'backup_time' => 'required_unless:backup_preset,hourly,custom|regex:/^\d{1,2}:\d{2}$/', 'backup_weekday' => 'required_if:backup_preset,weekly|integer|min:0|max:6', 'backup_monthday' => 'required_if:backup_preset,monthly|integer|min:1|max:28', 'backup_cron' => 'required_if:backup_preset,custom|string|max:64', 'backup_retention' => 'required|integer|min:1|max:365', ]; } protected function domainRules(): array { $host = 'required|string|max:190|regex:/^(?!https?:\/\/)(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/'; return [ 'ui_domain' => $host, 'mail_domain' => $host, 'webmail_domain'=> $host, ]; } protected function domainMessages(): array { $fmt = 'Ungültige Domain — kein Schema (http://) und kein Slash am Ende erlaubt.'; $req = 'Dieses Feld darf nicht leer sein.'; return [ 'ui_domain.required' => $req, 'mail_domain.required' => $req, 'webmail_domain.required' => $req, 'ui_domain.regex' => $fmt, 'mail_domain.regex' => $fmt, 'webmail_domain.regex' => $fmt, ]; } public function mount(): void { $this->instance_name = (string) (config('app.name') ?? 'Mailwolt'); $this->locale = (string) Setting::get('locale', $this->locale); $this->timezone = (string) Setting::get('timezone', $this->timezone); $this->session_timeout = (int) Setting::get('session_timeout', $this->session_timeout); $base = env('BASE_DOMAIN', ''); $persist = []; $uiSetting = Setting::get('ui_domain', null); if ($uiSetting !== null) { $this->ui_domain = (string) $uiSetting; } else { $this->ui_domain = (string) env('APP_HOST', ''); $persist['ui_domain'] = $this->ui_domain; } $mailSetting = Setting::get('mail_domain', null); if ($mailSetting !== null) { $this->mail_domain = (string) $mailSetting; } else { $mtaSub = env('MTA_SUB', ''); $this->mail_domain = $mtaSub && $base ? "{$mtaSub}.{$base}" : ''; $persist['mail_domain'] = $this->mail_domain; } $webmailSetting = Setting::get('webmail_domain', null); if ($webmailSetting !== null) { $this->webmail_domain = (string) $webmailSetting; } else { $webmailSub = env('WEBMAIL_SUB', ''); $webmailDomain = env('WEBMAIL_DOMAIN', ''); $this->webmail_domain = $webmailDomain ?: ($webmailSub && $base ? "{$webmailSub}.{$base}" : ''); $persist['webmail_domain'] = $this->webmail_domain; } if ($persist) { Setting::setMany($persist); } $this->loadSslStatus(); $defaultSenderName = ($this->instance_name ?: 'Mailwolt') . ' Benachrichtigung'; $defaultAdminEmail = auth()->user()?->system_notify_email ?? ''; $this->notify_sender_name = (string) Setting::get('notify_sender_name', $defaultSenderName); $this->notify_admin_email = (string) Setting::get('notify_admin_email', $defaultAdminEmail); $this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit); $this->password_min = (int) Setting::get('password_min', $this->password_min); // Backup aus BackupPolicy laden $policy = BackupPolicy::first(); if ($policy) { $this->backup_enabled = $policy->enabled; $this->backup_retention = $policy->retention_count; $this->backup_cron = $policy->schedule_cron; $this->backup_include_db = (bool) $policy->include_db; $this->backup_include_maildirs = (bool) $policy->include_maildirs; $this->backup_include_configs = (bool) $policy->include_configs; $this->backup_mail_dir = (string) Setting::get('backup_mail_dir', ''); $this->parseCronToPreset($policy->schedule_cron); } } public function save(): void { $this->validate(); Setting::setMany([ 'locale' => $this->locale, 'timezone' => $this->timezone, 'session_timeout' => $this->session_timeout, 'notify_sender_name' => $this->notify_sender_name, 'notify_admin_email' => $this->notify_admin_email, 'rate_limit' => $this->rate_limit, 'password_min' => $this->password_min, ]); // MAIL_FROM_NAME in .env synchronisieren if ($this->notify_sender_name) { $this->writeEnv(['MAIL_FROM_NAME' => $this->notify_sender_name]); } // Backup-Policy speichern $cron = $this->buildCron(); BackupPolicy::updateOrCreate([], [ 'enabled' => $this->backup_enabled, 'schedule_cron' => $cron, 'retention_count' => $this->backup_retention, 'include_db' => $this->backup_include_db, 'include_maildirs' => $this->backup_include_maildirs, 'include_configs' => $this->backup_include_configs, ]); Setting::setMany(['backup_mail_dir' => $this->backup_mail_dir]); $this->backup_cron = $cron; $this->dispatch('toast', type: 'done', badge: 'Einstellungen', title: 'Gespeichert', text: 'Alle Einstellungen wurden übernommen.', duration: 4000); } public function saveDomains(): void { $this->validate($this->domainRules(), $this->domainMessages()); $this->ui_domain = $this->cleanDomain($this->ui_domain); $this->mail_domain = $this->cleanDomain($this->mail_domain); $this->webmail_domain = $this->cleanDomain($this->webmail_domain); Setting::setMany([ 'ui_domain' => $this->ui_domain, 'mail_domain' => $this->mail_domain, 'webmail_domain' => $this->webmail_domain, ]); $this->syncDomainsToEnv(); $warnings = []; if ($this->ui_domain && $this->mail_domain && $this->webmail_domain) { $nginxOk = $this->applyDomains(false); if ($nginxOk === false) { $warnings[] = 'Nginx konnte nicht neu geladen werden — DNS noch nicht aktiv oder Helper fehlt.'; } } $sysmailErr = $this->syncSysmailDomain(); if ($sysmailErr) { $warnings[] = 'System-Domain: ' . $sysmailErr; } if ($this->mail_domain) { $this->applyPostfixHostname($this->mail_domain); } $this->loadSslStatus(); if ($warnings) { $this->dispatch('toast', type: 'warn', badge: 'Domains', title: 'Domains gespeichert — mit Hinweisen', text: implode(' · ', $warnings), duration: 0); } else { $this->dispatch('toast', type: 'done', badge: 'Domains', title: 'Domains gespeichert', text: 'Konfiguration wurde übernommen.', duration: 4000); } } private function syncSysmailDomain(): ?string { // BASE_DOMAIN aus config, env oder mail_domain ableiten $platformBase = strtolower((string) config('mailpool.platform_zone', '')); if (!$platformBase || $platformBase === 'example.com') { $platformBase = strtolower((string) env('BASE_DOMAIN', '')); } if (!$platformBase || $platformBase === 'example.com') { // Fallback: aus mail_domain den Hostnamen ohne ersten Subdomain-Teil $mailDomain = strtolower(trim((string) Setting::get('mail_domain', ''))); if ($mailDomain) { $parts = explode('.', $mailDomain); $platformBase = count($parts) > 2 ? implode('.', array_slice($parts, 1)) : $mailDomain; } } if (!$platformBase || $platformBase === 'example.com') return null; $systemSub = strtolower(config('mailpool.platform_system_zone', 'sysmail')); $expectedFqdn = "{$systemSub}.{$platformBase}"; $existing = \App\Models\Domain::where('is_system', true) ->where(fn($q) => $q->whereNull('is_server')->orWhere('is_server', false)) ->first(); if ($existing && $existing->domain === $expectedFqdn) return null; if ($existing && $existing->domain !== $expectedFqdn) { \App\Models\MailUser::where('domain_id', $existing->id)->forceDelete(); $existing->forceDelete(); } try { Artisan::call('db:seed', [ '--class' => 'Database\\Seeders\\SystemDomainSeeder', '--force' => true, ]); \Illuminate\Support\Facades\Log::info('SystemDomainSeeder ok', ['fqdn' => $expectedFqdn]); return null; } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::error('SystemDomainSeeder failed', [ 'fqdn' => $expectedFqdn, 'error' => $e->getMessage(), ]); return $e->getMessage(); } } private function cleanDomain(string $value): string { $value = trim($value); $value = preg_replace('#^https?://#i', '', $value); return rtrim($value, '/'); } public function loadSslStatus(): void { clearstatcache(true); $domains = [ 'ui' => $this->ui_domain, 'webmail' => $this->webmail_domain, 'mail' => $this->mail_domain, ]; $certs = []; foreach ($domains as $key => $domain) { if (! $domain) { $certs[$key] = ['domain' => '', 'has_cert' => false, 'expiry' => null, 'status' => 'nodomain']; continue; } $certFile = "/etc/letsencrypt/live/{$domain}/fullchain.pem"; $certDir = "/etc/letsencrypt/live/{$domain}"; // renewal/ ist immer 755 und world-readable — zuverlässigster Existenzcheck. // is_dir() als Fallback falls renewal-Datei fehlt (manuell ausgestellte Certs). $renewalConf = "/etc/letsencrypt/renewal/{$domain}.conf"; $certExists = file_exists($renewalConf) || is_dir($certDir); if (! $certExists) { $certs[$key] = ['domain' => $domain, 'has_cert' => false, 'expiry' => null, 'status' => 'missing']; continue; } // sudo nötig: archive/ ist 700 (Symlink-Ziel), voller Pfad für sudoers-Matching $expiry = trim((string) @shell_exec( "sudo -n /usr/bin/openssl x509 -enddate -noout -in " . escapeshellarg($certFile) . " 2>/dev/null | sed 's/notAfter=//'" )); $expiryTs = $expiry ? strtotime($expiry) : null; $daysLeft = $expiryTs ? (int) round(($expiryTs - time()) / 86400) : null; $certStatus = match (true) { $daysLeft === null => 'ok', $daysLeft <= 0 => 'expired', $daysLeft <= 14 => 'expiring', default => 'ok', }; $certs[$key] = [ 'domain' => $domain, 'has_cert' => true, 'expiry' => $expiryTs ? date('d.m.Y', $expiryTs) : null, 'days' => $daysLeft, 'status' => $certStatus, ]; } $this->sslCerts = $certs; } public function updatedUiDomain(): void { $this->validateOnly('ui_domain', $this->domainRules(), $this->domainMessages()); } public function updatedMailDomain(): void { $this->validateOnly('mail_domain', $this->domainRules(), $this->domainMessages()); } public function updatedWebmailDomain(): void { $this->validateOnly('webmail_domain', $this->domainRules(), $this->domainMessages()); } // Aktualisiert die Cron-Vorschau live wenn der User Felder ändert public function updatedBackupPreset(): void { $this->backup_cron = $this->buildCron(); } public function updatedBackupTime(): void { $this->backup_cron = $this->buildCron(); } public function updatedBackupWeekday(): void { $this->backup_cron = $this->buildCron(); } public function updatedBackupMonthday(): void{ $this->backup_cron = $this->buildCron(); } private function buildCron(): string { [$h, $m] = array_pad(explode(':', $this->backup_time ?? '03:00'), 2, '00'); $h = (int)$h; $m = (int)$m; return match($this->backup_preset) { 'hourly' => '0 * * * *', 'daily' => sprintf('%d %d * * *', $m, $h), 'weekly' => sprintf('%d %d * * %d', $m, $h, (int)$this->backup_weekday), 'monthly' => sprintf('%d %d %d * *', $m, $h, (int)$this->backup_monthday), default => $this->backup_cron ?: '0 3 * * *', }; } private function parseCronToPreset(string $cron): void { $p = preg_split('/\s+/', trim($cron)); if (count($p) !== 5) { $this->backup_preset = 'custom'; return; } [$min, $hour, $dom, $mon, $dow] = $p; if ($cron === '0 * * * *') { $this->backup_preset = 'hourly'; } elseif ($dom === '*' && $mon === '*' && $dow === '*' && is_numeric($hour)) { $this->backup_preset = 'daily'; $this->backup_time = sprintf('%02d:%02d', $hour, $min); } elseif ($dom === '*' && $mon === '*' && is_numeric($dow) && is_numeric($hour)) { $this->backup_preset = 'weekly'; $this->backup_time = sprintf('%02d:%02d', $hour, $min); $this->backup_weekday = $dow; } elseif ($mon === '*' && $dow === '*' && is_numeric($dom) && is_numeric($hour)) { $this->backup_preset = 'monthly'; $this->backup_time = sprintf('%02d:%02d', $hour, $min); $this->backup_monthday = $dom; } else { $this->backup_preset = 'custom'; } } private function applyDomains(bool $sslAuto = false): bool { // DNS prüfen — Warnung anzeigen, aber Nginx trotzdem versuchen $unreachable = []; foreach ([$this->ui_domain, $this->webmail_domain, $this->mail_domain] as $host) { if (! checkdnsrr($host, 'A') && ! checkdnsrr($host, 'AAAA')) { $unreachable[] = $host; } } if ($unreachable) { Log::info('mailwolt-apply-domains skipped (DNS not ready)', ['unreachable' => $unreachable]); return false; } $helper = '/usr/local/sbin/mailwolt-apply-domains'; $cmd = sprintf( 'sudo -n %s --ui-host %s --webmail-host %s --mail-host %s --ssl-auto %d 2>&1', escapeshellarg($helper), escapeshellarg($this->ui_domain), escapeshellarg($this->webmail_domain), escapeshellarg($this->mail_domain), $sslAuto ? 1 : 0, ); $output = shell_exec($cmd); $ok = str_contains((string) $output, 'fertig'); \Illuminate\Support\Facades\Log::info('mailwolt-apply-domains', ['output' => $output]); if ($ok) { Setting::set('ssl_configured', '1'); return true; } Log::warning('mailwolt-apply-domains failed', ['output' => $output]); return false; } private function syncDomainsToEnv(): void { // BASE_DOMAIN aus mail_domain ableiten (mx.pxo.at → pxo.at) $derivedBase = ''; if ($this->mail_domain) { $parts = explode('.', strtolower(trim($this->mail_domain))); $derivedBase = count($parts) > 2 ? implode('.', array_slice($parts, 1)) : implode('.', $parts); } $base = $derivedBase ?: rtrim((string) config('clubird.domain.base', ''), '.'); $sub = function (string $full) use ($base): string { $full = strtolower(trim($full)); if ($base && str_ends_with($full, '.' . $base)) { return substr($full, 0, -(strlen($base) + 1)); } $parts = explode('.', $full); return count($parts) > 1 ? $parts[0] : ''; }; $env = [ 'WEBMAIL_SUB' => $this->webmail_domain ? $sub($this->webmail_domain) : '', 'WEBMAIL_DOMAIN' => $this->webmail_domain ?: '', 'UI_SUB' => $this->ui_domain ? $sub($this->ui_domain) : '', 'MTA_SUB' => $this->mail_domain ? $sub($this->mail_domain) : '', ]; if ($derivedBase && $derivedBase !== 'example.com') { $env['BASE_DOMAIN'] = $derivedBase; } $this->writeEnv($env); Artisan::call('config:clear'); Artisan::call('route:clear'); } private function writeEnv(array $values): void { $path = base_path('.env'); $content = file_get_contents($path); foreach ($values as $key => $value) { $escaped = $value === '' ? '' : (str_contains($value, ' ') ? '"' . $value . '"' : $value); $line = $key . '=' . $escaped; $pattern = '/^' . preg_quote($key, '/') . '=[^\r\n]*/m'; if (preg_match($pattern, $content)) { $content = preg_replace($pattern, $line, $content); } else { $content .= "\n{$line}"; } } 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() { 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')); } }