295 lines
10 KiB
PHP
295 lines
10 KiB
PHP
<?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' => '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');
|
||
}
|
||
}
|