diff --git a/app/Models/Domain.php b/app/Models/Domain.php index f13143f..50a717c 100644 --- a/app/Models/Domain.php +++ b/app/Models/Domain.php @@ -9,7 +9,7 @@ class Domain extends Model { protected $fillable = [ 'domain','description','tags', - 'is_active','is_system', + 'is_active','is_system','is_server', 'max_aliases','max_mailboxes', 'default_quota_mb','max_quota_per_mailbox_mb','total_quota_mb', 'rate_limit_per_hour','rate_limit_override', @@ -19,6 +19,7 @@ class Domain extends Model 'tags' => 'array', 'is_active' => 'bool', 'is_system' => 'bool', + 'is_server' => 'boolean', 'max_aliases' => 'int', 'max_mailboxes' => 'int', 'default_quota_mb' => 'int', diff --git a/app/Services/TlsaService.php b/app/Services/TlsaService.php index 89d48b6..2afeb64 100644 --- a/app/Services/TlsaService.php +++ b/app/Services/TlsaService.php @@ -1,11 +1,13 @@ .tlsa.txt ab. + */ + public function refreshForServerDomain(Domain $serverDomain, string $service = '_25._tcp'): ?TlsaRecord { - $host = $this->resolveMtaHost(); - $certPath = "/etc/letsencrypt/live/{$host}/fullchain.pem"; + $host = $this->resolveMxHost(); $hash = $this->computeHashFromCert($host); - if (!$hash) return null; + if (!$hash) { + return null; // Zert (noch) nicht vorhanden + } + + $certPath = "/etc/letsencrypt/live/{$host}/fullchain.pem"; - // DB upsert (global, domain_id = null) $rec = TlsaRecord::updateOrCreate( - ['domain_id' => null, 'host' => $host, 'service' => $service], + ['domain_id' => $serverDomain->id, 'host' => $host, 'service' => $service], [ - 'usage' => 3, // DANE-EE - 'selector' => 1, // SPKI - 'matching' => 1, // SHA-256 + 'usage' => 3, // DANE-EE + 'selector' => 1, // SPKI + 'matching' => 1, // SHA-256 'hash' => $hash, 'cert_path' => $certPath, ] ); - // Datei – nur aktualisieren, wenn sich der Hash ändert + // Optional: TXT-Datei für Export/Debug @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"); - } + $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; } } + + +//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; +// } +// +// 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 (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, +// ] +// ); +// +// // 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; // diff --git a/database/migrations/2025_09_27_153255_create_domains_table.php b/database/migrations/2025_09_27_153255_create_domains_table.php index 658a3dc..2db6b97 100644 --- a/database/migrations/2025_09_27_153255_create_domains_table.php +++ b/database/migrations/2025_09_27_153255_create_domains_table.php @@ -18,6 +18,7 @@ return new class extends Migration $table->string('tags', 500)->nullable(); $table->boolean('is_active')->default(true)->index(); $table->boolean('is_system')->default(false); + $table->boolean('is_server')->default(false)->index(); $table->unsignedInteger('max_aliases')->default(400); $table->unsignedInteger('max_mailboxes')->default(10); $table->unsignedInteger('default_quota_mb')->default(3072); diff --git a/database/seeders/SystemDomainSeeder.php b/database/seeders/SystemDomainSeeder.php index 13f2751..89fd8d6 100644 --- a/database/seeders/SystemDomainSeeder.php +++ b/database/seeders/SystemDomainSeeder.php @@ -1,37 +1,71 @@ command->warn("BASE_DOMAIN ist 'example.com' – Seeder überspringt produktive Einträge."); return; } - $systemSub = config('mailpool.platform_system_zone'); - $base = "{$systemSub}.{$base}"; + $mtaSub = env('MTA_SUB', 'mx') ?: 'mx'; // z.B. mx + $serverFqdn = "{$mtaSub}.{$platformBase}"; // z.B. mx.nexlab.at - // Domain anlegen/holen - $domain = Domain::firstOrCreate( - ['domain' => $base], + $systemSub = config('mailpool.platform_system_zone') ?: 'sysmail'; // z.B. sysmail + $systemFqdn = "{$systemSub}.{$platformBase}"; // z.B. sysmail.nexlab.at + + // ========================================================================= + // 1) MAILSERVER-DOMAIN zuerst (mx.), is_server = true + // ========================================================================= + $serverDomain = Domain::firstOrCreate( + ['domain' => $serverFqdn], + ['is_active' => true, 'is_system' => false, 'is_server' => true] + ); + + if (!$serverDomain->is_server) { + $serverDomain->is_server = true; + $serverDomain->save(); + $this->command->info("Server-Host markiert: {$serverDomain->domain}"); + } + + // TLSA (3 1 1) nur in Laravel erzeugen/aktualisieren (falls LE-Zert bereits da ist) + if (! $serverDomain->tlsa()->exists()) { + $tlsa = app(\App\Services\TlsaService::class)->refreshForServerDomain($serverDomain); + if ($tlsa) { + $this->command->info("TLSA erstellt: _25._tcp.{$tlsa->host} 3 1 1 {$tlsa->hash}"); + } else { + $this->command->warn("TLSA übersprungen: LE-Zertifikat (noch) nicht vorhanden – später erneut seeden."); + } + } else { + $this->command->line("TLSA bereits vorhanden, übersprungen."); + } + + // ========================================================================= + // 2) SYSTEM-DOMAIN danach (sysmail.), is_system = true + // ========================================================================= + $systemDomain = Domain::firstOrCreate( + ['domain' => $systemFqdn], ['is_active' => true, 'is_system' => true] ); - // System Absender (no-reply) – ohne Passwort (kein Login) + // System-Absender (no-reply) – ohne Passwort (kein Login) MailUser::firstOrCreate( - ['email' => "no-reply@{$base}"], + ['email' => "no-reply@{$systemFqdn}"], [ - 'domain_id' => $domain->id, + 'domain_id' => $systemDomain->id, 'localpart' => 'no-reply', 'password_hash' => null, 'is_active' => true, @@ -42,26 +76,26 @@ class SystemDomainSeeder extends Seeder ); // DKIM – Key erzeugen, falls keiner aktiv existiert - if (! $domain->dkimKeys()->where('is_active', true)->exists()) { + if (!$systemDomain->dkimKeys()->where('is_active', true)->exists()) { [$privPem, $pubTxt] = $this->generateDkimKeyPair(); - $selector = 'mwl1'; // frei wählbar, z. B. rotierend später + $selector = 'mwl1'; // frei wählbar, später rotieren DkimKey::create([ - 'domain_id' => $domain->id, - 'selector' => $selector, - 'private_key_pem'=> $privPem, + 'domain_id' => $systemDomain->id, + 'selector' => $selector, + 'private_key_pem' => $privPem, 'public_key_txt' => $pubTxt, - 'is_active' => true, + 'is_active' => true, ]); - $this->command->info("DKIM angelegt: Host = {$selector}._domainkey.{$base}"); + $this->command->info("DKIM angelegt: Host = {$selector}._domainkey.{$systemFqdn}"); } - $dk = $domain->dkimKeys()->where('is_active', true)->latest()->first(); + $dk = $systemDomain->dkimKeys()->where('is_active', true)->latest()->first(); $dkimTxt = $dk ? "v=DKIM1; k=rsa; p={$dk->public_key_txt}" : null; app(DnsRecordService::class)->provision( - $domain, + $systemDomain, dkimSelector: $dk?->selector, dkimTxt: $dkimTxt, opts: [ @@ -71,8 +105,8 @@ class SystemDomainSeeder extends Seeder ] ); - $this->command->info("System-Domain '{$base}' fertig. SPF/DMARC/DKIM & DNS-Empfehlungen eingetragen."); - $this->printDnsHints($domain); + $this->command->info("System-Domain '{$systemFqdn}' fertig. SPF/DMARC/DKIM & DNS-Empfehlungen eingetragen."); + $this->printDnsHints($systemDomain); } /** @return array{0:string privatePem,1:string publicTxt} */ @@ -84,17 +118,16 @@ class SystemDomainSeeder extends Seeder ]); openssl_pkey_export($res, $privateKeyPem); $details = openssl_pkey_get_details($res); - // $details['key'] ist PEM, wir brauchen Base64 ohne Header/Footers $pubDer = $details['key']; - // Public PEM zu "p=" Wert (reines Base64) normalisieren - $pubTxt = trim(preg_replace('/-----(BEGIN|END) PUBLIC KEY-----|\s+/', '', $pubDer)); - - return [$privateKeyPem, $pubTxt]; + // Public PEM zu "p=" (reines Base64) normalisieren + $publicTxt = trim(preg_replace('/-----(BEGIN|END) PUBLIC KEY-----|\s+/', '', $pubDer)); + return [$privateKeyPem, $publicTxt]; } private function printDnsHints(Domain $domain): void { $base = $domain->domain; + $dkim = $domain->dkimKeys()->where('is_active', true)->latest()->first(); if ($dkim) { $this->command->line(" • DKIM TXT @ {$dkim->selector}._domainkey.{$base}"); @@ -114,3 +147,141 @@ class SystemDomainSeeder extends Seeder } } } + +// +//namespace Database\Seeders; +// +//use App\Models\DkimKey; +//use App\Models\Domain; +//use App\Models\MailUser; +//use App\Services\DnsRecordService; +//use Illuminate\Database\Seeder; +// +//class SystemDomainSeeder extends Seeder +//{ +// public function run(): void +// { +// $base = config('mailpool.platform_zone', 'example.com'); +// if (!$base || $base === 'example.com') { +// $this->command->warn("BASE_DOMAIN ist 'example.com' – Seeder überspringt produktive Einträge."); +// return; +// } +// +// $systemSub = config('mailpool.platform_system_zone'); +// $base = "{$systemSub}.{$base}"; +// +// // Domain anlegen/holen +// $domain = Domain::firstOrCreate( +// ['domain' => $base], +// ['is_active' => true, 'is_system' => true] +// ); +// +// // System Absender (no-reply) – ohne Passwort (kein Login) +// MailUser::firstOrCreate( +// ['email' => "no-reply@{$base}"], +// [ +// 'domain_id' => $domain->id, +// 'localpart' => 'no-reply', +// 'password_hash' => null, +// 'is_active' => true, +// 'is_system' => true, +// 'must_change_pw' => false, +// 'quota_mb' => 0, +// ] +// ); +// +// // DKIM – Key erzeugen, falls keiner aktiv existiert +// if (! $domain->dkimKeys()->where('is_active', true)->exists()) { +// [$privPem, $pubTxt] = $this->generateDkimKeyPair(); +// $selector = 'mwl1'; // frei wählbar, z. B. rotierend später +// +// DkimKey::create([ +// 'domain_id' => $domain->id, +// 'selector' => $selector, +// 'private_key_pem'=> $privPem, +// 'public_key_txt' => $pubTxt, +// 'is_active' => true, +// ]); +// +// $this->command->info("DKIM angelegt: Host = {$selector}._domainkey.{$base}"); +// } +// +// $dk = $domain->dkimKeys()->where('is_active', true)->latest()->first(); +// $dkimTxt = $dk ? "v=DKIM1; k=rsa; p={$dk->public_key_txt}" : null; +// +// app(DnsRecordService::class)->provision( +// $domain, +// dkimSelector: $dk?->selector, +// dkimTxt: $dkimTxt, +// opts: [ +// 'dmarc_policy' => 'none', +// 'spf_tail' => '-all', +// // optional: 'ipv4' => $serverIp, 'ipv6' => ... +// ] +// ); +// +// $base = config('mailpool.platform_zone', env('BASE_DOMAIN', 'example.com')); // z.B. nexlab.at +// $mta = env('MTA_SUB', 'mx') ?: 'mx'; +// $serverHostFqdn = "{$mta}.{$base}"; +// +// $serverHostDomain = \App\Models\Domain::firstOrCreate( +// ['domain' => $serverHostFqdn], +// ['is_active' => true, 'is_system' => false, 'is_server' => true] +// ); +// +// if (!$serverHostDomain->is_server) { +// $serverHostDomain->is_server = true; +// $serverHostDomain->save(); +// $this->command->info("Server-Host markiert: {$serverHostDomain->domain}"); +// } +// +// $tlsa = app(\App\Services\TlsaService::class)->refreshForServerDomain($serverHostDomain); +// if ($tlsa) { +// $this->command->info("TLSA gespeichert: _25._tcp.{$tlsa->host} 3 1 1 {$tlsa->hash}"); +// } else { +// $this->command->warn("TLSA übersprungen: LE-Zertifikat (noch) nicht vorhanden – später erneut seeden."); +// } +// +// $this->command->info("System-Domain '{$base}' fertig. SPF/DMARC/DKIM & DNS-Empfehlungen eingetragen."); +// $this->printDnsHints($domain); +// } +// +// /** @return array{0:string privatePem,1:string publicTxt} */ +// private function generateDkimKeyPair(): array +// { +// $res = openssl_pkey_new([ +// 'private_key_bits' => 2048, +// 'private_key_type' => OPENSSL_KEYTYPE_RSA, +// ]); +// openssl_pkey_export($res, $privateKeyPem); +// $details = openssl_pkey_get_details($res); +// // $details['key'] ist PEM, wir brauchen Base64 ohne Header/Footers +// $pubDer = $details['key']; +// // Public PEM zu "p=" Wert (reines Base64) normalisieren +// $pubTxt = trim(preg_replace('/-----(BEGIN|END) PUBLIC KEY-----|\s+/', '', $pubDer)); +// +// return [$privateKeyPem, $pubTxt]; +// } +// +// private function printDnsHints(Domain $domain): void +// { +// $base = $domain->domain; +// $dkim = $domain->dkimKeys()->where('is_active', true)->latest()->first(); +// if ($dkim) { +// $this->command->line(" • DKIM TXT @ {$dkim->selector}._domainkey.{$base}"); +// $this->command->line(" v=DKIM1; k=rsa; p={$dkim->public_key_txt}"); +// } +// +// $spf = $domain->spf()->where('is_active', true)->latest()->first(); +// if ($spf) { +// $this->command->line(" • SPF TXT @ {$base}"); +// $this->command->line(" {$spf->record_txt}"); +// } +// +// $dmarc = $domain->dmarc()->where('is_active', true)->latest()->first(); +// if ($dmarc) { +// $this->command->line(" • DMARC TXT @ _dmarc.{$base}"); +// $this->command->line(" " . ($dmarc->record_txt ?? $dmarc->renderTxt())); +// } +// } +//}