'staging', 'label' => 'Let’s Encrypt (Staging)'], ['value' => 'production', 'label' => 'Let’s Encrypt (Production)'], ]; public array $acme_challenges = [ ['value' => 'http01', 'label' => 'HTTP-01 (empfohlen)'], ['value' => 'dns01', 'label' => 'DNS-01 (Wildcard/komplex)'], ]; /* ========= MTA-STS ========= */ public bool $mta_sts_enabled = false; public string $mta_sts_mode = 'enforce'; // testing|enforce|none public int $mta_sts_max_age = 180; // Tage (Empfehlung: 180) public array $mta_sts_mx = ['*.example.com']; // neue Liste der mx-Ziele (mind. eins) // public bool $mta_sts_include_subdomains = false; // public string $mta_sts_serve_as = 'static'; // static|route /* ========= Zertifikatsliste (Demo) ========= */ public array $hosts = [ ['id' => 1, 'host' => 'mail.example.com', 'status' => 'ok', 'expires_at' => '2025-12-01'], ['id' => 2, 'host' => 'webmail.example.com', 'status' => 'expiring', 'expires_at' => '2025-10-20'], ['id' => 3, 'host' => 'mx.example.com', 'status' => 'missing', 'expires_at' => null], ]; /* ========= Computed (Previews) ========= */ public function getUiHostProperty(): string { return "{$this->ui_sub}.{$this->base_domain}"; } public function getWebmailHostProperty(): string { return "{$this->webmail_sub}.{$this->base_domain}"; } public function getMtaHostProperty(): string { return "{$this->mta_sub}.{$this->base_domain}"; } public function getMtaStsTxtNameProperty(): string { return "_mta-sts.{$this->base_domain}"; } public function getMtaStsTxtValueProperty(): string { // Beim tatsächlichen Schreiben bitte echten Timestamp/Version setzen. return 'v=STSv1; id=YYYYMMDD'; } /* ========= Validation ========= */ protected function rules(): array { return [ 'base_domain' => ['required','regex:/^(?:[a-z0-9-]+\.)+[a-z]{2,}$/i'], 'ui_sub' => ['required','regex:/^[a-z0-9-]+$/i'], 'webmail_sub' => ['required','regex:/^[a-z0-9-]+$/i'], 'mta_sub' => ['required','regex:/^[a-z0-9-]+$/i'], 'force_https' => ['boolean'], 'hsts' => ['boolean'], 'auto_renew' => ['boolean'], 'acme_contact' => ['required','email'], 'acme_env' => ['required', Rule::in(['staging','production'])], 'acme_challenge' => ['required', Rule::in(['http01','dns01'])], 'mta_sts_enabled' => ['boolean'], 'mta_sts_mode' => ['required', Rule::in(['testing','enforce','none'])], 'mta_sts_max_age' => ['integer','min:1','max:31536000'], 'mta_sts_mx' => ['array','min:1'], 'mta_sts_mx.*' => ['required','string','min:1'], // einfache Prüfung; optional regex auf Hostnamen // 'mta_sts_include_subdomains' => ['boolean'], // 'mta_sts_serve_as' => ['required', Rule::in(['static','route'])], ]; } /* ========= Lifecycle ========= */ public function updated($prop): void { // live normalisieren (keine Leerzeichen, keine Protokolle, nur [a-z0-9-]) if (in_array($prop, ['base_domain','ui_sub','webmail_sub','mta_sub'], true)) { $this->base_domain = $this->normalizeDomain($this->base_domain); $this->ui_sub = $this->sanitizeLabel($this->ui_sub); $this->webmail_sub = $this->sanitizeLabel($this->webmail_sub); $this->mta_sub = $this->sanitizeLabel($this->mta_sub); } } protected function loadMtaStsFromFileIfPossible(): void { $file = public_path('.well-known/mta-sts.txt'); if (!is_file($file)) return; $txt = file_get_contents($file); if (!$txt) return; $lines = preg_split('/\r\n|\r|\n/', $txt); $mx = []; foreach ($lines as $line) { [$k,$v] = array_pad(array_map('trim', explode(':', $line, 2)), 2, null); if (!$k) continue; if (strcasecmp($k,'version') === 0) { /* ignore */ } if (strcasecmp($k,'mode') === 0 && $v) $this->mta_sts_mode = strtolower($v); if (strcasecmp($k,'mx') === 0 && $v) $mx[] = $v; if (strcasecmp($k,'max_age') === 0 && is_numeric($v)) { $days = max(1, (int)round(((int)$v)/86400)); $this->mta_sts_max_age = $days; } } if ($mx) $this->mta_sts_mx = $mx; $this->mta_sts_enabled = true; } /* ========= Actions ========= */ public function saveDomains(): void { $this->validate(['base_domain','ui_sub','webmail_sub','mta_sub']); // TODO: persist $this->dispatch('toast', body: 'Domains gespeichert.'); } public function saveTls(): void { $this->validate(['force_https','hsts','auto_renew']); // TODO: persist $this->dispatch('toast', body: 'TLS/Redirect gespeichert.'); } public function saveAcme(): void { $this->validate(['acme_contact','acme_env','acme_challenge','auto_renew']); // TODO: persist (Kontakt, Env, Challenge, ggf. Auto-Renew Flag) $this->dispatch('toast', body: 'ACME-Einstellungen gespeichert.'); } public function saveMtaSts(): void { $this->validate([ 'mta_sts_enabled','mta_sts_mode','mta_sts_max_age','mta_sts_mx','mta_sts_mx.*' ]); // TODO: Settings persistieren (z.B. in einer settings-Tabelle) // Datei erzeugen/löschen $wellKnownDir = public_path('.well-known'); if (!is_dir($wellKnownDir)) { @mkdir($wellKnownDir, 0755, true); } $file = $wellKnownDir.'/mta-sts.txt'; if (!$this->mta_sts_enabled) { // Policy deaktiviert → Datei entfernen, falls vorhanden if (is_file($file)) @unlink($file); $this->dispatch('toast', body: 'MTA-STS deaktiviert und Datei entfernt.'); return; } $seconds = $this->mta_sts_max_age * 86400; $mxLines = collect($this->mta_sts_mx) ->filter(fn($v) => trim($v) !== '') ->map(fn($v) => "mx: ".trim($v)) ->implode("\n"); // Policy-Text (Plaintext) $text = "version: STSv1\n". "mode: {$this->mta_sts_mode}\n". "{$mxLines}\n". "max_age: {$seconds}\n"; file_put_contents($file, $text); $this->dispatch('toast', body: 'MTA-STS gespeichert & Datei aktualisiert.'); } public function addMx(): void { $suggest = '*.' . $this->base_domain; $this->mta_sts_mx[] = $suggest; } public function removeMx(int $index): void { if (isset($this->mta_sts_mx[$index])) { array_splice($this->mta_sts_mx, $index, 1); } if (count($this->mta_sts_mx) === 0) { $this->mta_sts_mx = ['*.' . $this->base_domain]; } } public function requestCertificate(int $hostId): void { // TODO: ACME ausstellen für Host-ID $this->dispatch('toast', body: 'Zertifikat wird angefordert …'); } public function renewCertificate(int $hostId): void { // TODO $this->dispatch('toast', body: 'Zertifikat wird erneuert …'); } public function revokeCertificate(int $hostId): void { // TODO $this->dispatch('toast', body: 'Zertifikat wird widerrufen …'); } /* ========= Helpers ========= */ protected function normalizeDomain(string $d): string { $d = strtolower(trim($d)); $d = preg_replace('/^https?:\/\//', '', $d); return rtrim($d, '.'); } protected function sanitizeLabel(string $s): string { return strtolower(preg_replace('/[^a-z0-9-]/i', '', $s ?? '')); } public function daysLeft(?string $iso): ?int { if (!$iso) return null; try { $d = (new DateTimeImmutable($iso))->setTime(0,0); $now = (new DateTimeImmutable('today')); return (int)$now->diff($d)->format('%r%a'); } catch (\Throwable) { return null; } } public function statusBadge(array $row): array { $days = $this->daysLeft($row['expires_at']); if ($row['status'] === 'missing') { return ['class' => 'bg-white/8 text-white/50 border-white/10', 'text' => 'kein Zertifikat']; } if ($days === null) { return ['class' => 'bg-white/8 text-white/50 border-white/10', 'text' => '—']; } if ($days <= 5) return ['class' => 'bg-rose-500/10 text-rose-300 border-rose-400/30', 'text' => "{$days}d"]; if ($days <= 20) return ['class' => 'bg-amber-400/10 text-amber-300 border-amber-300/30','text' => "{$days}d"]; return ['class' => 'bg-emerald-500/10 text-emerald-300 border-emerald-400/30','text' => "{$days}d"]; } public function render() { return view('livewire.ui.system.domains-ssl-form'); } }