Anpassen der Tlsa Record erstellung

main
boban 2025-10-17 00:30:09 +02:00
parent 2e59e65eb6
commit 44a3056de9
6 changed files with 406 additions and 107 deletions

View File

@ -1,95 +1,269 @@
<?php
namespace App\Console\Commands;
use App\Models\Domain;
use App\Models\TlsaRecord;
use App\Support\Hostnames;
use App\Services\TlsaService;
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}';
{--domainId= : ID der Domain (optional, alternativ --domainName)}
{--domainName= : Domain-Name wie in domains.name}
{--host= : expliziter MTA-FQDN; sonst per Service/ENV ermittelt}
{--service=_25._tcp}
{--usage=3}
{--selector=1}
{--matching=1}
{--no-file : TLSA nicht als Datei schreiben}';
protected $description = 'Erzeugt/aktualisiert einen TLSA-Record und speichert ihn in tlsa_records.';
protected $description = 'Erzeugt/aktualisiert TLSA (3 1 1) in DB (und optional Datei)';
public function handle(): int
public function handle(TlsaService $tlsa): int
{
$domain = Domain::find($this->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');
// Lets 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');
//
// // Lets 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;
// }
//}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Console\Commands;
use App\Services\TlsaService;
use Illuminate\Console\Command;
class TlsaRefresh extends Command
{
protected $signature = 'dns:tlsa:refresh';
protected $description = 'Aktualisiert TLSA (3 1 1) für den MX-Host (idempotent).';
public function handle(TlsaService $tlsa): int
{
if (app()->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;
}
}

View File

@ -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');
}
}

View File

@ -291,7 +291,7 @@ class DomainCreateModal extends ModalComponent
]
);
app(TlsaService::class)->createForDomain($domain);
// app(TlsaService::class)->createForDomain($domain);
// UI
$this->dispatch('domain-created');

View File

@ -1,47 +1,124 @@
<?php
namespace App\Services;
use App\Models\Domain;
use App\Models\TlsaRecord;
use Illuminate\Support\Facades\Log;
class TlsaService
{
/**
* Erstellt/aktualisiert TLSA für SMTP (_25._tcp) am MTA-Host.
* host = <MTA_SUB>.<BASE_DOMAIN> (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;
// }
//}

View File

@ -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