From 44a3056de9023184dff95510bcca916ceec95274 Mon Sep 17 00:00:00 2001 From: boban Date: Fri, 17 Oct 2025 00:30:09 +0200 Subject: [PATCH] Anpassen der Tlsa Record erstellung --- app/Console/Commands/GenerateTlsaRecord.php | 318 ++++++++++++++---- app/Console/Commands/TlsaRefresh.php | 32 ++ app/Helpers/helpers.php | 23 +- .../Ui/Domain/Modal/DomainCreateModal.php | 2 +- app/Services/TlsaService.php | 135 ++++++-- database/seeders/SystemDomainSeeder.php | 3 - 6 files changed, 406 insertions(+), 107 deletions(-) create mode 100644 app/Console/Commands/TlsaRefresh.php diff --git a/app/Console/Commands/GenerateTlsaRecord.php b/app/Console/Commands/GenerateTlsaRecord.php index cef8e0e..a8b319a 100644 --- a/app/Console/Commands/GenerateTlsaRecord.php +++ b/app/Console/Commands/GenerateTlsaRecord.php @@ -1,95 +1,269 @@ argument('domainId')); + $domain = $this->resolveDomain(); if (!$domain) { - $this->error('Domain nicht gefunden.'); + $this->error('Domain nicht gefunden (nutze --domainId oder --domainName).'); return self::FAILURE; } - // Host bestimmen - $host = trim($this->option('host') ?: Hostnames::mta()); - if (!str_contains($host, '.')) { - $this->error("Ungültiger Host: {$host}"); + $host = trim($this->option('host') ?: $tlsa->resolveMtaHost($domain)); + $service = $this->option('service') ?: '_25._tcp'; + $usage = (int)$this->option('usage'); + $selector = (int)$this->option('selector'); + $matching = (int)$this->option('matching'); + $write = !$this->option('no-file'); + + $record = $tlsa->upsertTlsa($domain, $host, $service, $usage, $selector, $matching, $write); + + if (!$record) { + $this->warn("TLSA konnte nicht erzeugt werden (Zertifikat fehlt?): {$host}"); return self::FAILURE; } - $service = $this->option('service') ?: '_25._tcp'; - $usage = (int) $this->option('usage'); - $selector = (int) $this->option('selector'); - $matching = (int) $this->option('matching'); - - // Let’s Encrypt Pfad (ggf. anpassen, falls anderes CA/Verzeichnis) - $certPath = "/etc/letsencrypt/live/{$host}/fullchain.pem"; - - if (!is_file($certPath)) { - $this->error("Zertifikat nicht gefunden: {$certPath}"); - $this->line('Tipp: LE deploy hook/renewal erst durchlaufen lassen oder Pfad anpassen.'); - return self::FAILURE; - } - - // Hash über SPKI (selector=1) + SHA-256 (matching=1) - $cmd = "openssl x509 -in ".escapeshellarg($certPath)." -noout -pubkey" - . " | openssl pkey -pubin -outform DER" - . " | openssl dgst -sha256"; - $proc = Process::fromShellCommandline($cmd); - $proc->run(); - - if (!$proc->isSuccessful()) { - $this->error('Fehler bei der Hash-Erzeugung (openssl).'); - $this->line($proc->getErrorOutput()); - return self::FAILURE; - } - - $hash = preg_replace('/^SHA256\(stdin\)=\s*/', '', trim($proc->getOutput())); - - $record = TlsaRecord::updateOrCreate( - [ - 'domain_id' => $domain->id, - 'host' => $host, - 'service' => $service, - ], - [ - 'usage' => $usage, - 'selector' => $selector, - 'matching' => $matching, - 'hash' => $hash, - 'cert_path' => $certPath, - ] - ); - - $this->info('✅ TLSA gespeichert'); - $this->line(sprintf( - '%s.%s IN TLSA %d %d %d %s', - $record->service, - $record->host, - $record->usage, - $record->selector, - $record->matching, - $record->hash - )); - + $this->info("✅ TLSA gespeichert: {$service}.{$host} (3 1 1 {$record->hash})"); return self::SUCCESS; } + + private function resolveDomain(): ?Domain + { + $id = $this->option('domainId'); + $name = $this->option('domainName'); + + if ($id) return Domain::find($id); + if ($name) return Domain::where('name', $name)->first(); + + // Fallback: erste Domain + return Domain::first(); + } } + +//namespace App\Console\Commands; +// +//use App\Models\Domain; +//use App\Models\TlsaRecord; +//use Illuminate\Console\Command; +//use Symfony\Component\Process\Process; +// +//class GenerateTlsaRecord extends Command +//{ +// protected $signature = 'dns:tlsa +// {domainId : ID der Domain in eurer domains-Tabelle} +// {--host= : FQDN des MTA-Hosts (z.B. mx.domain.com)} +// {--service=_25._tcp : TLSA Service-Präfix, Standard _25._tcp} +// {--usage=3 : 3 = DANE-EE} +// {--selector=1 : 1 = SPKI} +// {--matching=1 : 1 = SHA-256}'; +// +// protected $description = 'Liest vorhandene TLSA-Datei oder erzeugt neuen Eintrag und speichert ihn in tlsa_records.'; +// +// public function handle(): int +// { +// $domain = Domain::find($this->argument('domainId')); +// if (!$domain) { +// $this->error('Domain nicht gefunden.'); +// return self::FAILURE; +// } +// +// $host = trim($this->option('host')); +// $service = $this->option('service') ?: '_25._tcp'; +// $usage = (int)$this->option('usage'); +// $selector = (int)$this->option('selector'); +// $matching = (int)$this->option('matching'); +// +// $dnsDir = '/etc/mailwolt/dns'; +// $file = "{$dnsDir}/{$host}.tlsa.txt"; +// $certPath = "/etc/letsencrypt/live/{$host}/fullchain.pem"; +// +// // Prüfen ob bereits Datei existiert +// if (is_file($file)) { +// $this->info("📄 TLSA-Datei gefunden: {$file}"); +// $line = trim(file_get_contents($file)); +// +// if (preg_match('/IN TLSA (\d) (\d) (\d) ([A-Fa-f0-9]+)/', $line, $m)) { +// $usage = (int)$m[1]; +// $selector = (int)$m[2]; +// $matching = (int)$m[3]; +// $hash = $m[4]; +// } else { +// $this->warn("Ungültiges TLSA-Format in {$file}, regeneriere …"); +// $hash = $this->generateHash($certPath); +// } +// } else { +// $this->info("🧩 Keine TLSA-Datei gefunden, generiere neu …"); +// $hash = $this->generateHash($certPath); +// +// if (!is_dir($dnsDir)) { +// mkdir($dnsDir, 0755, true); +// } +// file_put_contents($file, sprintf( +// "%s.%s IN TLSA %d %d %d %s\n", +// $service, +// $host, +// $usage, +// $selector, +// $matching, +// $hash +// )); +// } +// +// // In DB speichern +// $record = TlsaRecord::updateOrCreate( +// [ +// 'domain_id' => $domain->id, +// 'host' => $host, +// 'service' => $service, +// ], +// [ +// 'usage' => $usage, +// 'selector' => $selector, +// 'matching' => $matching, +// 'hash' => $hash, +// 'cert_path' => $certPath, +// ] +// ); +// +// $this->info("✅ TLSA gespeichert für {$host}"); +// return self::SUCCESS; +// } +// +// private function generateHash(string $certPath): string +// { +// if (!is_file($certPath)) { +// throw new \RuntimeException("Zertifikat nicht gefunden: {$certPath}"); +// } +// +// $cmd = sprintf( +// 'openssl x509 -in %s -noout -pubkey | openssl pkey -pubin -outform DER | openssl dgst -sha256', +// escapeshellarg($certPath) +// ); +// $proc = Process::fromShellCommandline($cmd); +// $proc->run(); +// +// if (!$proc->isSuccessful()) { +// throw new \RuntimeException('Fehler bei der Hash-Erzeugung: ' . $proc->getErrorOutput()); +// } +// +// return preg_replace('/^SHA256\(stdin\)=\s*/', '', trim($proc->getOutput())); +// } +//} +// +//----- + + +// +//namespace App\Console\Commands; +// +//use App\Models\Domain; +//use App\Models\TlsaRecord; +//use App\Support\Hostnames; +//use Illuminate\Console\Command; +//use Symfony\Component\Process\Process; +// +//class GenerateTlsaRecord extends Command +//{ +// protected $signature = 'dns:tlsa +// {domainId : ID der Domain in eurer domains-Tabelle} +// {--host= : FQDN des MTA-Hosts (z.B. mailsrv012.domain.com)} +// {--service=_25._tcp : TLSA Service-Präfix, Standard _25._tcp} +// {--usage=3 : 3 = DANE-EE} +// {--selector=1 : 1 = SPKI} +// {--matching=1 : 1 = SHA-256}'; +// +// protected $description = 'Erzeugt/aktualisiert einen TLSA-Record und speichert ihn in tlsa_records.'; +// +// public function handle(): int +// { +// $domain = Domain::find($this->argument('domainId')); +// if (!$domain) { +// $this->error('Domain nicht gefunden.'); +// return self::FAILURE; +// } +// +// // Host bestimmen +// $host = trim($this->option('host') ?: Hostnames::mta()); +// if (!str_contains($host, '.')) { +// $this->error("Ungültiger Host: {$host}"); +// return self::FAILURE; +// } +// +// $service = $this->option('service') ?: '_25._tcp'; +// $usage = (int) $this->option('usage'); +// $selector = (int) $this->option('selector'); +// $matching = (int) $this->option('matching'); +// +// // Let’s Encrypt Pfad (ggf. anpassen, falls anderes CA/Verzeichnis) +// $certPath = "/etc/letsencrypt/live/{$host}/fullchain.pem"; +// +// if (!is_file($certPath)) { +// $this->error("Zertifikat nicht gefunden: {$certPath}"); +// $this->line('Tipp: LE deploy hook/renewal erst durchlaufen lassen oder Pfad anpassen.'); +// return self::FAILURE; +// } +// +// // Hash über SPKI (selector=1) + SHA-256 (matching=1) +// $cmd = "openssl x509 -in ".escapeshellarg($certPath)." -noout -pubkey" +// . " | openssl pkey -pubin -outform DER" +// . " | openssl dgst -sha256"; +// $proc = Process::fromShellCommandline($cmd); +// $proc->run(); +// +// if (!$proc->isSuccessful()) { +// $this->error('Fehler bei der Hash-Erzeugung (openssl).'); +// $this->line($proc->getErrorOutput()); +// return self::FAILURE; +// } +// +// $hash = preg_replace('/^SHA256\(stdin\)=\s*/', '', trim($proc->getOutput())); +// +// $record = TlsaRecord::updateOrCreate( +// [ +// 'domain_id' => $domain->id, +// 'host' => $host, +// 'service' => $service, +// ], +// [ +// 'usage' => $usage, +// 'selector' => $selector, +// 'matching' => $matching, +// 'hash' => $hash, +// 'cert_path' => $certPath, +// ] +// ); +// +// $this->info('✅ TLSA gespeichert'); +// $this->line(sprintf( +// '%s.%s IN TLSA %d %d %d %s', +// $record->service, +// $record->host, +// $record->usage, +// $record->selector, +// $record->matching, +// $record->hash +// )); +// +// return self::SUCCESS; +// } +//} diff --git a/app/Console/Commands/TlsaRefresh.php b/app/Console/Commands/TlsaRefresh.php new file mode 100644 index 0000000..b805bfe --- /dev/null +++ b/app/Console/Commands/TlsaRefresh.php @@ -0,0 +1,32 @@ +environment(['local', 'development'])) { + $this->info('TLSA: übersprungen in nicht-Produktivumgebung.'); + return self::SUCCESS; + } + if (config('app.base_domain', env('BASE_DOMAIN', 'example.com')) === 'example.com') { + $this->info('TLSA: übersprungen für example.com.'); + return self::SUCCESS; + } + + $rec = $tlsa->refreshForMx(); + if (!$rec) { + $this->warn('TLSA konnte nicht aktualisiert werden (Zertifikat fehlt?).'); + return self::FAILURE; + } + $this->info("TLSA ok: {$rec->service}.{$rec->host} 3 1 1 {$rec->hash}"); + return self::SUCCESS; + } +} diff --git a/app/Helpers/helpers.php b/app/Helpers/helpers.php index 99070af..405f676 100644 --- a/app/Helpers/helpers.php +++ b/app/Helpers/helpers.php @@ -29,8 +29,27 @@ if (!function_exists('webmail_host')) { } if (!function_exists('mta_host')) { - function mta_host(): string + function mta_host(?int $domainId = null): string { - return domain_host(env('MTA_SUB', 'mx')); + // 1️⃣ Vorrang: Datenbankwert (z. B. aus der domains-Tabelle) + if ($domainId) { + try { + $domain = \App\Models\Domain::find($domainId); + if ($domain && !empty($domain->mta_host)) { + return $domain->mta_host; + } + } catch (\Throwable $e) { + // DB evtl. noch nicht migriert — fallback auf env + } + } + + // 2️⃣ ENV-Variante (z. B. MTA_SUB=mail01) + $sub = env('MTA_SUB'); + if ($sub) { + return domain_host($sub); + } + + // 3️⃣ Notfall: statischer Fallback + return domain_host('mx'); } } diff --git a/app/Livewire/Ui/Domain/Modal/DomainCreateModal.php b/app/Livewire/Ui/Domain/Modal/DomainCreateModal.php index 9b6dadc..d45da6e 100644 --- a/app/Livewire/Ui/Domain/Modal/DomainCreateModal.php +++ b/app/Livewire/Ui/Domain/Modal/DomainCreateModal.php @@ -291,7 +291,7 @@ class DomainCreateModal extends ModalComponent ] ); - app(TlsaService::class)->createForDomain($domain); +// app(TlsaService::class)->createForDomain($domain); // UI $this->dispatch('domain-created'); diff --git a/app/Services/TlsaService.php b/app/Services/TlsaService.php index 9b414c0..89d48b6 100644 --- a/app/Services/TlsaService.php +++ b/app/Services/TlsaService.php @@ -1,47 +1,124 @@ . (z.B. mx.example.com) - */ - public function createForDomain(Domain $domain): void + public function resolveMtaHost(): string { - $mtaHost = (env('MTA_SUB', 'mx') ?: 'mx') . '.' . env('BASE_DOMAIN', 'example.com'); - $service = '_25._tcp'; - $certPath = "/etc/letsencrypt/live/{$mtaHost}/fullchain.pem"; + $base = env('BASE_DOMAIN', 'example.com'); + $sub = env('MTA_SUB', 'mx') ?: 'mx'; + return "{$sub}.{$base}"; + } - if (!is_file($certPath)) { - Log::warning("TLSA skipped: Zertifikat fehlt: {$certPath}"); - return; - } + public function computeHashFromCert(string $host): ?string + { + $certPath = "/etc/letsencrypt/live/{$host}/fullchain.pem"; + if (!is_file($certPath)) return null; - // SHA256 über SubjectPublicKeyInfo $cmd = "openssl x509 -in ".escapeshellarg($certPath)." -noout -pubkey" . " | openssl pkey -pubin -outform DER" - . " | openssl dgst -sha256 | awk '{print \$2}'"; - $hash = trim((string)@shell_exec($cmd)); + . " | openssl dgst -sha256"; + $out = shell_exec($cmd.' 2>/dev/null') ?? ''; + $hash = preg_replace('/^SHA256\(stdin\)=\s*/', '', trim($out)); + return $hash !== '' ? $hash : null; + } - if ($hash === '') { - Log::error("TLSA failed: Hash konnte nicht berechnet werden ({$mtaHost})"); - return; - } + public function refreshForMx(string $service = '_25._tcp'): ?TlsaRecord + { + $host = $this->resolveMtaHost(); + $certPath = "/etc/letsencrypt/live/{$host}/fullchain.pem"; + $hash = $this->computeHashFromCert($host); + if (!$hash) return null; - TlsaRecord::updateOrCreate( - ['domain_id' => $domain->id, 'service' => $service, 'host' => $mtaHost], + // DB upsert (global, domain_id = null) + $rec = TlsaRecord::updateOrCreate( + ['domain_id' => null, 'host' => $host, 'service' => $service], [ - 'usage' => 3, // DANE-EE - 'selector' => 1, // SPKI - 'matching' => 1, // SHA-256 - 'hash' => $hash, - 'cert_path'=> $certPath, + 'usage' => 3, // DANE-EE + 'selector' => 1, // SPKI + 'matching' => 1, // SHA-256 + 'hash' => $hash, + 'cert_path' => $certPath, ] ); + + // Datei – nur aktualisieren, wenn sich der Hash ändert + @mkdir('/etc/mailwolt/dns', 0755, true); + $file = "/etc/mailwolt/dns/{$host}.tlsa.txt"; + $newLine = sprintf('%s.%s IN TLSA %d %d %d %s', $service, $host, 3, 1, 1, $hash); + + $needWrite = true; + if (is_file($file)) { + $current = trim((string)file_get_contents($file)); + if ($current === $newLine) { + $needWrite = false; + } + } + if ($needWrite) { + file_put_contents($file, $newLine."\n"); + } + + return $rec; } } +// +//namespace App\Services; +// +//use App\Models\TlsaRecord; +// +//class TlsaService +//{ +// public function resolveMtaHost(): string +// { +// $base = env('BASE_DOMAIN', 'example.com'); +// $sub = env('MTA_SUB', 'mx') ?: 'mx'; +// return "{$sub}.{$base}"; +// } +// +// public function computeHashFromCert(string $host): ?string +// { +// $certPath = "/etc/letsencrypt/live/{$host}/fullchain.pem"; +// if (!is_file($certPath)) return null; +// +// $cmd = "openssl x509 -in ".escapeshellarg($certPath)." -noout -pubkey" +// . " | openssl pkey -pubin -outform DER" +// . " | openssl dgst -sha256"; +// $out = shell_exec($cmd.' 2>/dev/null') ?? ''; +// $hash = preg_replace('/^SHA256\(stdin\)=\s*/', '', trim($out)); +// return $hash !== '' ? $hash : null; +// } +// +// /** +// * Schreibt/aktualisiert TLSA in DB (global) + Datei unter /etc/mailwolt/dns/. +// * Wir speichern ohne domain_id (global, nur pro Host/Service). +// */ +// public function refreshForMx(string $service = '_25._tcp'): ?TlsaRecord +// { +// $host = $this->resolveMtaHost(); +// $certPath = "/etc/letsencrypt/live/{$host}/fullchain.pem"; +// $hash = $this->computeHashFromCert($host); +// if (!$hash) return null; +// +// // DB upsert (domain_id = null → globaler Eintrag) +// $rec = TlsaRecord::updateOrCreate( +// ['domain_id' => null, 'host' => $host, 'service' => $service], +// [ +// 'usage' => 3, // DANE-EE +// 'selector' => 1, // SPKI +// 'matching' => 1, // SHA-256 +// 'hash' => $hash, +// 'cert_path' => $certPath, +// ] +// ); +// +// // Datei schreiben (für externen DNS-Export etc.) +// @mkdir('/etc/mailwolt/dns', 0755, true); +// $line = sprintf('%s.%s IN TLSA %d %d %d %s', +// $service, $host, 3, 1, 1, $hash); +// file_put_contents("/etc/mailwolt/dns/{$host}.tlsa.txt", $line."\n"); +// +// return $rec; +// } +//} diff --git a/database/seeders/SystemDomainSeeder.php b/database/seeders/SystemDomainSeeder.php index fbf7134..13f2751 100644 --- a/database/seeders/SystemDomainSeeder.php +++ b/database/seeders/SystemDomainSeeder.php @@ -3,12 +3,9 @@ namespace Database\Seeders; use App\Models\DkimKey; -use App\Models\DmarcRecord; use App\Models\Domain; use App\Models\MailUser; -use App\Models\SpfRecord; use App\Services\DnsRecordService; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class SystemDomainSeeder extends Seeder