Feature: Dedizierter SSL-Abschnitt in Einstellungen mit Echtzeit-Fortschritt
- Neuer Abschnitt "SSL-Zertifikate" in den Einstellungen:
· Zeigt pro Domain (UI, Webmail, Mailserver) ob LE-Cert vorhanden ist,
Ablaufdatum und Status (OK / fehlt / läuft ab / abgelaufen)
· Button "Let's Encrypt Zertifikate einrichten" startet Provisioning
im Hintergrund (nohup, non-blocking)
· Live-Fortschrittsanzeige per wire:poll.2s mit Status-Icons pro Domain
(pending → running → done/error/skip)
- saveDomains() ruft apply-domains jetzt ohne certbot auf (--ssl-auto 0) —
Domains speichern und SSL einrichten sind damit getrennte Aktionen
- loadSslStatus() liest Certbot-Zertifikat-Info direkt aus openssl
- spin-Keyframe für Spinner-Animation in app.css
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
v1.1.269
parent
c4a2f33293
commit
f740ffc753
|
|
@ -24,6 +24,14 @@ class SettingsForm extends Component
|
|||
public string $mail_domain = '';
|
||||
public string $webmail_domain = '';
|
||||
|
||||
// SSL provisioning
|
||||
public bool $sslProvisioning = false;
|
||||
public bool $sslDone = false;
|
||||
public array $sslProgress = ['ui' => 'pending', 'webmail' => 'pending', 'mail' => 'pending'];
|
||||
public array $sslCerts = [];
|
||||
|
||||
private const SSL_STATE_DIR = '/var/lib/mailwolt/wizard';
|
||||
|
||||
// Sicherheit
|
||||
public int $rate_limit = 5;
|
||||
public int $password_min = 10;
|
||||
|
|
@ -123,6 +131,7 @@ class SettingsForm extends Component
|
|||
Setting::setMany($persist);
|
||||
}
|
||||
|
||||
$this->loadSslStatus();
|
||||
$this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit);
|
||||
$this->password_min = (int) Setting::get('password_min', $this->password_min);
|
||||
|
||||
|
|
@ -188,12 +197,13 @@ class SettingsForm extends Component
|
|||
// Immer .env aktualisieren — unabhängig von DNS oder Nginx
|
||||
$this->syncDomainsToEnv();
|
||||
|
||||
// Nginx-Konfiguration nur anwenden wenn alle Domains gesetzt sind
|
||||
// Nginx-Konfiguration (ohne certbot) — nur wenn alle Domains gesetzt sind
|
||||
if ($this->ui_domain && $this->mail_domain && $this->webmail_domain) {
|
||||
$sslAuto = app()->isProduction();
|
||||
$this->applyDomains($sslAuto);
|
||||
$this->applyDomains(false);
|
||||
}
|
||||
|
||||
$this->loadSslStatus();
|
||||
|
||||
$this->dispatch('toast', type: 'done', badge: 'Domains',
|
||||
title: 'Domains gespeichert',
|
||||
text: 'Konfiguration wurde übernommen.', duration: 4000);
|
||||
|
|
@ -223,6 +233,94 @@ class SettingsForm extends Component
|
|||
$this->applyDomains(true);
|
||||
}
|
||||
|
||||
public function startSslProvisioning(): void
|
||||
{
|
||||
if (! ($this->ui_domain && $this->webmail_domain)) {
|
||||
$this->dispatch('toast', type: 'warn', badge: 'SSL',
|
||||
title: 'Domains fehlen',
|
||||
text: 'Bitte erst UI-Domain und Webmail-Domain speichern.', duration: 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
@mkdir(self::SSL_STATE_DIR, 0755, true);
|
||||
@unlink(self::SSL_STATE_DIR . '/done');
|
||||
foreach (['ui', 'mail', 'webmail'] as $k) {
|
||||
file_put_contents(self::SSL_STATE_DIR . "/{$k}", 'pending');
|
||||
}
|
||||
|
||||
$this->sslProgress = ['ui' => 'pending', 'webmail' => 'pending', 'mail' => 'pending'];
|
||||
$this->sslDone = false;
|
||||
$this->sslProvisioning = true;
|
||||
|
||||
$artisan = base_path('artisan');
|
||||
$cmd = sprintf(
|
||||
'nohup php %s mailwolt:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=1 > /dev/null 2>&1 &',
|
||||
escapeshellarg($artisan),
|
||||
escapeshellarg($this->ui_domain),
|
||||
escapeshellarg($this->mail_domain),
|
||||
escapeshellarg($this->webmail_domain),
|
||||
);
|
||||
@shell_exec($cmd);
|
||||
}
|
||||
|
||||
public function pollSsl(): void
|
||||
{
|
||||
if (! $this->sslProvisioning || $this->sslDone) return;
|
||||
|
||||
foreach (['ui', 'mail', 'webmail'] as $key) {
|
||||
$file = self::SSL_STATE_DIR . "/{$key}";
|
||||
$this->sslProgress[$key] = is_readable($file)
|
||||
? trim((string) @file_get_contents($file))
|
||||
: 'pending';
|
||||
}
|
||||
|
||||
$done = @file_get_contents(self::SSL_STATE_DIR . '/done');
|
||||
if ($done !== false) {
|
||||
$this->sslDone = true;
|
||||
$this->sslProvisioning = false;
|
||||
$this->loadSslStatus();
|
||||
}
|
||||
}
|
||||
|
||||
public function loadSslStatus(): void
|
||||
{
|
||||
$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";
|
||||
if (! file_exists($certFile)) {
|
||||
$certs[$key] = ['domain' => $domain, 'has_cert' => false, 'expiry' => null, 'status' => 'missing'];
|
||||
continue;
|
||||
}
|
||||
$expiry = trim((string) @shell_exec("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()); }
|
||||
|
|
|
|||
|
|
@ -2462,3 +2462,5 @@ textarea.sec-input { height: auto; line-height: 1.55; padding: 8px 10px; resize:
|
|||
.mq-pag-btn:hover:not(:disabled) { background: var(--mw-bg3); color: var(--mw-t1); }
|
||||
.mq-pag-btn.active { background: rgba(124,58,237,.2); border-color: rgba(124,58,237,.5); color: #c4b5fd; }
|
||||
.mq-pag-btn:disabled { opacity: .35; cursor: default; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
|
|
|||
|
|
@ -285,22 +285,114 @@
|
|||
</span>
|
||||
<span wire:loading wire:target="saveDomains">Speichert…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- SSL-Zertifikate --}}
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info">
|
||||
<span class="mbx-badge-mute">SSL-Zertifikate</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:12px">
|
||||
|
||||
{{-- Cert-Status-Tabelle --}}
|
||||
@php
|
||||
$certLabels = ['ui' => 'UI', 'webmail' => 'Webmail', 'mail' => 'Mailserver'];
|
||||
@endphp
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
@foreach(['ui','webmail','mail'] as $key)
|
||||
@php $c = $sslCerts[$key] ?? null; @endphp
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:9px 11px;border-radius:7px;background:var(--mw-bg3);border:1px solid var(--mw-b2)">
|
||||
{{-- Icon --}}
|
||||
@if(!$c || $c['status'] === 'nodomain')
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:var(--mw-t4)"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
@elseif($c['status'] === 'ok')
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:#22c55e"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><path d="M5 9.5l1.3 1.3 2.7-2.7" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
@elseif($c['status'] === 'expiring')
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:#f59e0b"><path d="M7 1.5L12.5 11H1.5L7 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/><path d="M7 5.5v2.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><circle cx="7" cy="9.5" r=".6" fill="currentColor"/></svg>
|
||||
@else
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:#f87171"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M5 5l4 4M9 5l-4 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
@endif
|
||||
{{-- Label + Domain --}}
|
||||
<div style="flex:1;min-width:0">
|
||||
<span style="font-size:11px;font-weight:500;color:var(--mw-t3)">{{ $certLabels[$key] }}</span>
|
||||
<span style="font-size:10.5px;color:var(--mw-t4);margin-left:6px;font-family:monospace">{{ $c['domain'] ?? '—' }}</span>
|
||||
</div>
|
||||
{{-- Status --}}
|
||||
@if(!$c || $c['status'] === 'nodomain')
|
||||
<span style="font-size:10.5px;color:var(--mw-t4)">Keine Domain</span>
|
||||
@elseif($c['status'] === 'missing')
|
||||
<span style="font-size:10.5px;color:#f87171">Fehlt</span>
|
||||
@elseif($c['status'] === 'expired')
|
||||
<span style="font-size:10.5px;color:#f87171">Abgelaufen</span>
|
||||
@elseif($c['status'] === 'expiring')
|
||||
<span style="font-size:10.5px;color:#f59e0b">Läuft ab in {{ $c['days'] }} Tagen</span>
|
||||
@elseif($c['status'] === 'ok')
|
||||
<span style="font-size:10.5px;color:#22c55e">Gültig bis {{ $c['expiry'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Fortschritt während Provisioning --}}
|
||||
@if($sslProvisioning || $sslDone)
|
||||
@if($sslProvisioning)
|
||||
<div wire:poll.2s="pollSsl"></div>
|
||||
@endif
|
||||
<div style="display:flex;flex-direction:column;gap:6px;padding:10px 12px;border-radius:8px;background:rgba(99,102,241,.06);border:1px solid rgba(99,102,241,.2)">
|
||||
<div style="font-size:11px;font-weight:600;color:var(--mw-t2);margin-bottom:2px">
|
||||
{{ $sslProvisioning ? 'Zertifikate werden eingerichtet…' : 'Einrichtung abgeschlossen' }}
|
||||
</div>
|
||||
@foreach(['ui' => 'UI', 'webmail' => 'Webmail', 'mail' => 'Mailserver'] as $key => $label)
|
||||
@php $st = $sslProgress[$key] ?? 'pending'; @endphp
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
@if($st === 'done')
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="flex-shrink:0;color:#22c55e"><circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.1"/><path d="M3.5 6l1.8 1.8 3.2-3.2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
@elseif($st === 'error' || $st === 'nodns')
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="flex-shrink:0;color:#f87171"><circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.1"/><path d="M4 4l4 4M8 4l-4 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
@elseif($st === 'skip')
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="flex-shrink:0;color:var(--mw-t4)"><circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.1"/><path d="M4 6h4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
@elseif($st === 'running')
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="flex-shrink:0;color:#818cf8;animation:spin 1s linear infinite"><circle cx="6" cy="6" r="4.5" stroke="currentColor" stroke-width="1.3" stroke-dasharray="16 8" stroke-linecap="round"/></svg>
|
||||
@else
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="flex-shrink:0;color:var(--mw-t4)"><circle cx="6" cy="6" r="4.5" stroke="currentColor" stroke-width="1.1" stroke-dasharray="3 3"/></svg>
|
||||
@endif
|
||||
<span style="font-size:11px;color:{{ in_array($st,['error','nodns']) ? '#f87171' : ($st==='done' ? '#22c55e' : 'var(--mw-t3)') }}">
|
||||
{{ $label }}
|
||||
@if($st === 'nodns') <span style="font-size:10px"> — kein DNS-Eintrag</span>
|
||||
@elseif($st === 'error') <span style="font-size:10px"> — fehlgeschlagen</span>
|
||||
@elseif($st === 'skip') <span style="font-size:10px;color:var(--mw-t4)"> — kein Zertifikat nötig</span>
|
||||
@elseif($st === 'running') <span style="font-size:10px;color:var(--mw-t4)"> — läuft…</span>
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Buttons --}}
|
||||
@if(app()->isProduction())
|
||||
<div style="display:flex;align-items:center;gap:6px;padding:8px 10px;background:rgba(34,197,94,.06);border:1px solid rgba(34,197,94,.2);border-radius:7px">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="color:#22c55e;flex-shrink:0"><path d="M2 6.5l2.5 2.5 5.5-5.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span style="font-size:11.5px;color:rgba(34,197,94,.85)">Let's Encrypt SSL wird automatisch eingerichtet</span>
|
||||
</div>
|
||||
<button wire:click="startSslProvisioning"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="startSslProvisioning"
|
||||
@disabled($sslProvisioning)
|
||||
class="mbx-btn-primary" style="width:100%;justify-content:center;font-size:12px">
|
||||
<svg width="11" height="11" viewBox="0 0 14 14" fill="none"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
<span wire:loading.remove wire:target="startSslProvisioning">
|
||||
{{ $sslProvisioning ? 'Läuft…' : 'Let\'s Encrypt Zertifikate einrichten' }}
|
||||
</span>
|
||||
<span wire:loading wire:target="startSslProvisioning">Startet…</span>
|
||||
</button>
|
||||
@else
|
||||
<div style="display:flex;align-items:flex-start;gap:8px;padding:8px 10px;background:rgba(99,102,241,.06);border:1px solid rgba(99,102,241,.2);border-radius:7px">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="color:#818cf8;flex-shrink:0;margin-top:1px"><circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.2"/><path d="M6 5.5v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6" cy="4" r=".6" fill="currentColor"/></svg>
|
||||
<span style="font-size:11.5px;color:rgba(129,140,248,.9);line-height:1.5">Local-Modus — SSL wird beim Speichern übersprungen (NPM übernimmt)</span>
|
||||
</div>
|
||||
<button wire:click="openSslModal"
|
||||
class="mbx-btn-mute" style="width:100%;justify-content:center;font-size:11.5px">
|
||||
<svg width="11" height="11" viewBox="0 0 14 14" fill="none"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
SSL manuell erzwingen (certbot)
|
||||
</button>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue