mailwolt/app/Services/DnsRecordService.php

352 lines
14 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\Services;
use App\Models\DkimKey;
use App\Models\Domain;
use Illuminate\Support\Facades\Log;
class DnsRecordService
{
/**
* High-level: DKIM (optional), SPF & DMARC in DB anlegen/aktualisieren
* und empfohlene DNS-Records (required/optional) zurückgeben.
*
* $opts:
* - ipv4, ipv6
* - spf_tail ("~all" | "-all")
* - spf_extra (Array zusätzlicher Mechanismen, z.B. ["ip4:1.2.3.4"])
* - dmarc_policy ("none"|"quarantine"|"reject")
* - rua ("mailto:foo@bar.tld")
*/
public function provision(Domain $domain, ?string $dkimSelector = null, ?string $dkimTxt = null, array $opts = []): array
{
// --- Defaults aus ENV/Config ---
$opts = array_replace([
'ipv4' => env('SERVER_PUBLIC_IPV4'),
'ipv6' => env('SERVER_PUBLIC_IPV6'),
'spf_tail' => config('mailpool.spf_tail', '~all'),
'spf_extra' => [],
'dmarc_policy' => config('mailpool.dmarc_policy', 'none'),
'rua' => "mailto:dmarc@{$domain->domain}",
], $opts);
// --- DKIM aus DB ziehen falls nicht übergeben ---
if (!$dkimSelector || !$dkimTxt) {
/** @var DkimKey|null $dk */
$dk = $domain->dkimKeys()->where('is_active', true)->latest()->first();
if ($dk) {
$dkimSelector = $dk->selector;
$dkimTxt = "v=DKIM1; k=rsa; p={$dk->public_key_txt}";
}
}
// --- SPF/DMARC in DB persistieren (oder aktualisieren) ---
$spfTxt = $this->buildSpfTxt($domain, $opts);
$dmarcTxt = $this->buildDmarcTxt($domain, $opts['dmarc_policy'], $opts['rua']);
// spf_records
if (method_exists($domain, 'spf')) {
$domain->spf()->updateOrCreate(
['is_active' => true],
['record_txt' => $spfTxt]
);
}
// dmarc_records
if (method_exists($domain, 'dmarc')) {
$domain->dmarc()->updateOrCreate(
['policy' => $opts['dmarc_policy']],
[
'rua' => $opts['rua'],
'pct' => 100,
'record_txt' => $dmarcTxt,
'is_active' => true,
]
);
}
// --- DNS-Empfehlungen berechnen ---
$records = $this->buildForDomain($domain, array_merge($opts, [
'dkim_selector' => $dkimSelector,
'dkim_txt' => $dkimTxt,
]));
// Optional in eine eigene dnsRecords()-Relation persistieren
$this->persist($domain, $records);
return $records;
}
/** SPF-String zusammenbauen (mx a [+extra] + tail) */
public function buildSpfTxt(Domain $domain, array $opts = []): string
{
$tail = $opts['spf_tail'] ?? '~all';
$extra = $opts['spf_extra'] ?? [];
$parts = ['v=spf1', 'mx', 'a'];
// Falls Server-IP explizit praktisch fürs On-Prem
if (!empty($opts['ipv4'])) $parts[] = 'ip4:' . $opts['ipv4'];
if (!empty($opts['ipv6'])) $parts[] = 'ip6:' . $opts['ipv6'];
foreach ($extra as $m) {
$m = trim((string)$m);
if ($m !== '') $parts[] = $m;
}
$parts[] = $tail; // "~all" oder "-all"
return implode(' ', $parts);
}
/** DMARC-String bauen (p=policy; rua=mailto:..; pct=100) */
public function buildDmarcTxt(Domain $domain, string $policy = 'none', string $rua = null): string
{
$rua = $rua ?: "mailto:dmarc@{$domain->domain}";
return "v=DMARC1; p={$policy}; rua={$rua}; pct=100";
}
// -----------------------------------------------------------
// Ab hier deine vorhandenen Helfer (leicht erweitert):
// -----------------------------------------------------------
public function buildForDomain(Domain $domain, array $opts = []): array
{
$baseDomain = env('BASE_DOMAIN', 'example.com');
$uiSub = env('UI_SUB', 'ui');
$webSub = env('WEBMAIL_SUB', 'webmail');
$mxSub = env('MTA_SUB', 'mx');
$uiHost = $uiSub ? "{$uiSub}.{$baseDomain}" : $baseDomain;
$webmail = $webSub ? "{$webSub}.{$baseDomain}" : $baseDomain;
$mtaHost = $mxSub ? "{$mxSub}.{$baseDomain}" : $baseDomain;
$ipv4 = $opts['ipv4'] ?? null;
$ipv6 = $opts['ipv6'] ?? null;
$spfTxt = $opts['spf_txt'] ?? $this->buildSpfTxt($domain, $opts);
$dmarcTxt = $opts['dmarc_txt'] ?? $this->buildDmarcTxt($domain, $opts['dmarc_policy'] ?? 'none', $opts['rua'] ?? null);
$dkimSelector = $opts['dkim_selector'] ?? null;
$dkimTxt = $opts['dkim_txt'] ?? null;
$R = fn($type,$name,$value,$ttl=3600) => compact('type','name','value','ttl');
if ($dkimSelector && is_string($dkimTxt)) {
$dkimTxt = trim($dkimTxt);
// Falls nur Base64 geliefert: auf DKIM-Format heben
if ($dkimTxt !== '' && !str_starts_with($dkimTxt, 'v=DKIM1')) {
if (!preg_match('/^[A-Za-z0-9+\/=]+$/', $dkimTxt)) {
Log::warning('DKIM TXT invalid chars', ['len'=>strlen($dkimTxt)]);
$dkimTxt = ''; // hart ablehnen statt kaputt speichern
} else {
$dkimTxt = "v=DKIM1; k=rsa; p={$dkimTxt}";
}
}
if ($dkimTxt !== '') {
$required[] = $R('TXT', "{$dkimSelector}._domainkey.{$domain->domain}", $dkimTxt);
}
}
$required = [
$R('MX', $domain->domain, "10 {$mtaHost}."),
$R('TXT', $domain->domain, $spfTxt),
$R('TXT', "_dmarc.{$domain->domain}", $dmarcTxt),
];
if ($dkimSelector && $dkimTxt) {
$required[] = $R('TXT', "{$dkimSelector}._domainkey.{$domain->domain}", $dkimTxt);
}
$optional = [
$R('CAA', $domain->domain, '0 issue "letsencrypt.org"'),
$R('CNAME', "webmail.{$domain->domain}", "{$webmail}."),
$R('CNAME', "ui.{$domain->domain}", "{$uiHost}."),
$R('SRV', "_submission._tcp.{$domain->domain}", "0 1 587 {$mtaHost}."),
$R('SRV', "_imaps._tcp.{$domain->domain}", "0 1 993 {$mtaHost}."),
$R('SRV', "_pop3s._tcp.{$domain->domain}", "0 1 995 {$mtaHost}."),
$R('CNAME', "autoconfig.{$domain->domain}", "{$uiHost}."),
$R('CNAME', "autodiscover.{$domain->domain}", "{$uiHost}."),
];
if (method_exists($domain, 'tlsaRecords')) {
$tlsa = $domain->tlsaRecords()
->where('service', '_25._tcp')
->latest()
->first();
if ($tlsa) {
$optional[] = $R(
'TLSA',
"{$tlsa->service}.{$tlsa->host}",
"{$tlsa->usage} {$tlsa->selector} {$tlsa->matching} {$tlsa->hash}"
);
}
}
if ($ipv4) $optional[] = $R('A', $domain->domain, $ipv4);
if ($ipv6) $optional[] = $R('AAAA', $domain->domain, $ipv6);
return ['required'=>$required,'optional'=>$optional];
}
public function persist(Domain $domain, array $records): void
{
if (!method_exists($domain, 'dnsRecords')) return;
$upsert = function(array $rec) use ($domain) {
$domain->dnsRecords()->updateOrCreate(
['type'=>$rec['type'], 'name'=>$rec['name']],
['value'=>$rec['value'], 'ttl'=>$rec['ttl'], 'is_managed'=>true]
);
};
foreach ($records['required'] ?? [] as $r) $upsert($r);
foreach ($records['optional'] ?? [] as $r) $upsert($r);
}
// public function buildForDomain(Domain $domain, array $opts = []): array
// {
// // ---- Aus ENV lesen (deine Installer-Variablen) ----
// $baseDomain = env('BASE_DOMAIN', 'example.com');
// $uiSub = env('UI_SUB', 'ui');
// $webSub = env('WEBMAIL_SUB', 'webmail');
// $mxSub = env('MTA_SUB', 'mx');
//
// // Ziel-Hosts (wohin die Kundendomain zeigen soll)
// $uiHost = $uiSub ? "{$uiSub}.{$baseDomain}" : $baseDomain;
// $webmail = $webSub ? "{$webSub}.{$baseDomain}" : $baseDomain;
// $mtaHost = $mxSub ? "{$mxSub}.{$baseDomain}" : $baseDomain;
//
// // Public IPs (falls gesetzt; sonst leer -> nur Anzeige)
// $ipv4 = $opts['ipv4'] ?? env('SERVER_PUBLIC_IPV4'); // z.B. vom Installer in .env geschrieben
// $ipv6 = $opts['ipv6'] ?? env('SERVER_PUBLIC_IPV6');
//
// // Policies
// $dmarcPolicy = $opts['dmarc_policy'] ?? 'none'; // none | quarantine | reject
// $spfTail = $opts['spf_tail'] ?? '~all'; // ~all | -all (streng)
//
// // DKIM (neuester aktiver Key)
// /** @var DkimKey|null $dkim */
// $dkim = $domain->dkimKeys()->where('is_active', true)->latest()->first();
// $dkimSelector = $dkim?->selector ?: 'dkim';
// $dkimTxt = $dkim ? "v=DKIM1; k=rsa; p={$dkim->public_key_txt}" : null;
//
// // Helper
// $R = fn(string $type, string $name, string $value, int $ttl = 3600) => [
// 'type' => $type, 'name' => $name, 'value' => $value, 'ttl' => $ttl,
// ];
//
// // ========== REQUIRED ==========
// $required = [];
//
// // MX (zeigt auf dein globales MX-Host)
// $required[] = $R('MX', $domain->domain, "10 {$mtaHost}.");
//
// // SPF: „mx“ + optional a (du hattest vorher -all; hier konfigurierbar)
// $spf = trim("v=spf1 mx a {$spfTail}");
// $required[] = $R('TXT', $domain->domain, $spf);
//
// // DKIM (nur wenn Key vorhanden)
// if ($dkimTxt) {
// $required[] = $R('TXT', "{$dkimSelector}._domainkey.{$domain->domain}", $dkimTxt);
// }
//
// // DMARC (Default p=$dmarcPolicy + RUA)
// $required[] = $R('TXT', "_dmarc.{$domain->domain}", "v=DMARC1; p={$dmarcPolicy}; rua=mailto:dmarc@{$domain->domain}; pct=100");
//
// // ========== OPTIONAL (empfohlen) ==========
// $optional = [];
//
// // A/AAAA für Root NUR falls du Root direkt terminierst (sonst weglassen)
// if ($ipv4) $optional[] = $R('A', $domain->domain, $ipv4);
// if ($ipv6) $optional[] = $R('AAAA', $domain->domain, $ipv6);
//
// // CAA für ACME/Lets Encrypt
// // 0 issue "letsencrypt.org" | optional: 0 iodef "mailto:admin@domain"
// $optional[] = $R('CAA', $domain->domain, '0 issue "letsencrypt.org"');
//
// // CNAMEs für UI/Webmail in der Kundenzone -> zeigen auf deine globalen Hosts
// $optional[] = $R('CNAME', "webmail.{$domain->domain}", "{$webmail}.");
// $optional[] = $R('CNAME', "ui.{$domain->domain}", "{$uiHost}.");
//
// // SRV (nutzerfreundliche Autokonfigs)
// // _submission._tcp STARTTLS Port 587
// $optional[] = $R('SRV', "_submission._tcp.{$domain->domain}", "0 1 587 {$mtaHost}.");
// // _imaps._tcp / _pop3s._tcp (falls aktiv)
// $optional[] = $R('SRV', "_imaps._tcp.{$domain->domain}", "0 1 993 {$mtaHost}.");
// $optional[] = $R('SRV', "_pop3s._tcp.{$domain->domain}", "0 1 995 {$mtaHost}.");
//
// // Autoconfig / Autodiscover (wenn du sie anbieten willst)
// // CNAMEs auf deine UI/Webmail (oder A/AAAA, wenn du echte Subdomains je Kunde willst)
// $optional[] = $R('CNAME', "autoconfig.{$domain->domain}", "{$uiHost}.");
// $optional[] = $R('CNAME', "autodiscover.{$domain->domain}", "{$uiHost}.");
//
// return [
// 'required' => $required,
// 'optional' => $optional,
// 'meta' => [
// 'mx_target' => $mtaHost,
// 'ui_target' => $uiHost,
// 'webmail_target'=> $webmail,
// 'dkim_selector' => $dkimSelector,
// 'has_dkim' => (bool) $dkimTxt,
// 'tips' => [
// 'rDNS' => 'Reverse DNS der Server-IP sollte auf den MX-Host zeigen.',
// ],
// ],
// ];
// }
//
// /**
// * Optional: Empfohlene/benötigte Records in deiner DB speichern.
// * Nutzt $domain->dnsRecords() falls vorhanden. Andernfalls einfach nicht verwenden.
// */
// public function persist(Domain $domain, array $records): void
// {
// if (!method_exists($domain, 'dnsRecords')) {
// return;
// }
//
// $upsert = function(array $rec) use ($domain) {
// $domain->dnsRecords()->updateOrCreate(
// ['type' => $rec['type'], 'name' => $rec['name']],
// ['value' => $rec['value'], 'ttl' => $rec['ttl'], 'is_managed' => true]
// );
// };
//
// foreach (($records['required'] ?? []) as $r) $upsert($r);
// foreach (($records['optional'] ?? []) as $r) $upsert($r);
// }
//
// /**
// * Erzeugt empfohlene DNS-Records für eine neue Domain
// * und speichert sie (falls Domain->dnsRecords() existiert).
// */
// public function createRecommendedRecords(
// Domain $domain,
// ?string $dkimSelector = null,
// ?string $dkimTxt = null,
// array $opts = []
// ): array {
// // Fallback, falls kein DKIM-Schlüssel übergeben wurde
// if (!$dkimSelector || !$dkimTxt) {
// $dkim = $domain->dkimKeys()->where('is_active', true)->latest()->first();
// $dkimSelector ??= $dkim?->selector ?? 'dkim';
// $dkimTxt ??= $dkim ? "v=DKIM1; k=rsa; p={$dkim->public_key_txt}" : null;
// }
//
// // DNS-Empfehlungen generieren
// $records = $this->buildForDomain($domain, array_merge($opts, [
// 'dkim_selector' => $dkimSelector,
// 'dkim_txt' => $dkimTxt,
// ]));
//
// // Falls möglich -> in Datenbank persistieren
// $this->persist($domain, $records);
//
// return $records;
// }
}