352 lines
14 KiB
PHP
352 lines
14 KiB
PHP
<?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/Let’s 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;
|
||
// }
|
||
}
|