mailwolt/app/Livewire/Ui/System/DomainsSslForm.php

295 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Livewire\Ui\System;
use DateTimeImmutable;
use Illuminate\Validation\Rule;
use Livewire\Component;
class DomainsSslForm extends Component
{
/* ========= Basis & Hosts ========= */
public string $base_domain = 'example.com';
// nur Subdomain-Teile (ohne Punkte/Protokoll)
public string $ui_sub = 'mail';
public string $webmail_sub = 'webmail';
public string $mta_sub = 'mx';
/* ========= TLS / Redirect ========= */
public bool $force_https = true;
public bool $hsts = true;
public bool $auto_renew = true;
/* ========= ACME ========= */
public string $acme_contact = 'admin@example.com';
public string $acme_env = 'staging'; // staging|production
public string $acme_challenge = 'http01'; // http01|dns01
public array $acme_envs = [
['value' => 'staging', 'label' => 'Lets Encrypt (Staging)'],
['value' => 'production', 'label' => 'Lets 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');
}
}