505 lines
20 KiB
PHP
505 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Ui\System;
|
|
|
|
use App\Models\BackupPolicy;
|
|
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 = 'allgemein';
|
|
|
|
// Allgemein
|
|
public string $instance_name = '';
|
|
public string $locale = 'de';
|
|
public string $timezone = 'Europe/Berlin';
|
|
public int $session_timeout = 120;
|
|
|
|
// Domains
|
|
public string $ui_domain = '';
|
|
public string $mail_domain = '';
|
|
public string $webmail_domain = '';
|
|
|
|
// SSL-Status (read-only, für Anzeige in Einstellungen)
|
|
public array $sslCerts = [];
|
|
|
|
private const SSL_STATE_DIR = '/var/lib/mailwolt/wizard';
|
|
|
|
// Benachrichtigungen
|
|
public string $notify_sender_name = '';
|
|
public string $notify_admin_email = '';
|
|
|
|
// Sicherheit
|
|
public int $rate_limit = 5;
|
|
public int $password_min = 10;
|
|
|
|
// Backup
|
|
public bool $backup_enabled = false;
|
|
public string $backup_preset = 'daily';
|
|
public string $backup_time = '03:00';
|
|
public string $backup_weekday = '1';
|
|
public string $backup_monthday = '1';
|
|
public string $backup_cron = '0 3 * * *';
|
|
public int $backup_retention = 7;
|
|
public bool $backup_include_db = true;
|
|
public bool $backup_include_maildirs = true;
|
|
public bool $backup_include_configs = true;
|
|
public string $backup_mail_dir = '';
|
|
|
|
protected function rules(): array
|
|
{
|
|
return [
|
|
'locale' => '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;
|
|
}
|
|
|
|
$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);
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
$timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL);
|
|
return view('livewire.ui.system.settings-form', compact('timezones'));
|
|
}
|
|
}
|