From 6a071ffdf27d18e1fd743224f84b0f6900925bb8 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 2 Apr 2026 21:52:46 +0200 Subject: [PATCH] feat: forcer le resolver DNS 1.1.1.1 via dig + fallback dns_get_record src/Service/DnsCheckService.php: - Constante RESOLVER = '1.1.1.1' (Cloudflare DNS) - Methode dig() utilise la commande dig @1.1.1.1 pour toutes les requetes DNS afin d'avoir des resultats coherents quel que soit le resolver local du serveur - isDigAvailable(): detecte si dig est installe (cache static) - fallbackDnsGetRecord(): quand dig n'est pas installe, utilise dns_get_record() PHP natif et formate la sortie au format dig +noall +answer pour que le parsing reste identique - getTxtRecords(), getCnameRecord(), getMxRecords(), getSrvRecords() utilisent tous dig() en interne - getCnameRecord() et getSrvRecords() rendues publiques pour utilisation par la commande src/Command/CheckDnsCommand.php: - Suppression du check DKIM generique (DKIM verifie uniquement via AWS SES avec les 3 CNAME individuels par domaine) - checkDnsRecordExists(), checkMxExists(), checkTxtContains() utilisent maintenant $this->dnsCheck au lieu de dns_get_record() direct - getCnameRecord() supprimee de la commande (delegue au service) - getMxValues() et getTxtSpfValue() utilisent le service docker/php/dev/Dockerfile: - Ajout du paquet dnsutils (fournit la commande dig) docker/php/prod/Dockerfile: - Ajout du paquet dnsutils (fournit la commande dig) Co-Authored-By: Claude Opus 4.6 (1M context) --- docker/php/dev/Dockerfile | 1 + docker/php/prod/Dockerfile | 1 + src/Command/CheckDnsCommand.php | 48 ++++++------ src/Service/DnsCheckService.php | 132 ++++++++++++++++++++++++++------ 4 files changed, 136 insertions(+), 46 deletions(-) diff --git a/docker/php/dev/Dockerfile b/docker/php/dev/Dockerfile index e574dc1..c1ef511 100644 --- a/docker/php/dev/Dockerfile +++ b/docker/php/dev/Dockerfile @@ -14,6 +14,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ unzip \ qpdf \ curl \ + dnsutils \ python3 \ python3-pip \ git \ diff --git a/docker/php/prod/Dockerfile b/docker/php/prod/Dockerfile index 8e891c3..8b4d737 100644 --- a/docker/php/prod/Dockerfile +++ b/docker/php/prod/Dockerfile @@ -12,6 +12,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libfreetype-dev \ libmagickwand-dev \ unzip \ + dnsutils \ && rm -rf /var/lib/apt/lists/* \ && docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install \ diff --git a/src/Command/CheckDnsCommand.php b/src/Command/CheckDnsCommand.php index caf5b64..76d9aa5 100644 --- a/src/Command/CheckDnsCommand.php +++ b/src/Command/CheckDnsCommand.php @@ -70,7 +70,7 @@ class CheckDnsCommand extends Command $this->dnsCheck->checkDmarc($domain, $checks, $errors, $successes); $this->enrichWithCloudflare($checks, '_dmarc.'.$domain, 'DMARC', 'TXT', $cfRecords); - $this->dnsCheck->checkDkim($domain, $checks, $errors, $warnings, $successes); + // DKIM verifie via AWS SES (3 CNAME individuels), pas de check generique $this->dnsCheck->checkMx($domain, self::EXPECTED_MX[$domain] ?? '', $checks, $errors, $successes); $this->enrichWithCloudflare($checks, $domain, 'MX', 'MX', $cfRecords); @@ -220,7 +220,7 @@ class CheckDnsCommand extends Command foreach ($dkim['tokens'] as $token) { $expectedCname = $token.'.dkim.amazonses.com'; $dkimFqdn = $token.'._domainkey.'.$domain; - $actualCname = $this->getCnameRecord($dkimFqdn); + $actualCname = $this->dnsCheck->getCnameRecord($dkimFqdn); $found = null !== $actualCname && str_contains($actualCname, 'dkim.amazonses.com'); $checks[] = DnsCheckService::check( @@ -310,10 +310,9 @@ class CheckDnsCommand extends Command private function getMxValues(string $domain): string { - $records = dns_get_record($domain, \DNS_MX) ?: []; $values = []; - foreach ($records as $mx) { - $values[] = rtrim($mx['target'] ?? '', '.').' (pri: '.($mx['pri'] ?? '?').')'; + foreach ($this->dnsCheck->getMxRecords($domain) as $mx) { + $values[] = $mx['target'].' (pri: '.$mx['pri'].')'; } return implode(', ', $values); @@ -321,11 +320,14 @@ class CheckDnsCommand extends Command private function getTxtSpfValue(string $domain): string { - $records = dns_get_record($domain, \DNS_TXT) ?: []; - foreach ($records as $r) { - $txt = $r['txt'] ?? ''; - if (str_starts_with($txt, 'v=spf1')) { - return $txt; + $output = $this->dnsCheck->dig($domain, 'TXT'); + + foreach (explode("\n", $output) as $line) { + if (preg_match('/\bIN\s+TXT\s+"(.+)"/', $line, $m)) { + $txt = str_replace('" "', '', $m[1]); + if (str_starts_with($txt, 'v=spf1')) { + return $txt; + } } } @@ -403,16 +405,16 @@ class CheckDnsCommand extends Command return match ($type) { 'MX' => $this->checkMxExists($name, $expectedContent), 'TXT' => $this->checkTxtContains($name, $expectedContent), - 'CNAME' => null !== $this->getCnameRecord($name), - 'SRV' => [] !== (dns_get_record($name, \DNS_SRV) ?: []), + 'CNAME' => null !== $this->dnsCheck->getCnameRecord($name), + 'SRV' => [] !== $this->dnsCheck->getSrvRecords($name), default => false, }; } private function checkMxExists(string $name, string $target): bool { - foreach (dns_get_record($name, \DNS_MX) ?: [] as $mx) { - if (str_contains(rtrim($mx['target'] ?? '', '.'), $target)) { + foreach ($this->dnsCheck->getMxRecords($name) as $mx) { + if (str_contains($mx['target'], $target)) { return true; } } @@ -422,22 +424,20 @@ class CheckDnsCommand extends Command private function checkTxtContains(string $name, string $start): bool { - foreach (dns_get_record($name, \DNS_TXT) ?: [] as $r) { - if (str_starts_with($r['txt'] ?? '', $start)) { - return true; + $output = $this->dnsCheck->dig($name, 'TXT'); + + foreach (explode("\n", $output) as $line) { + if (preg_match('/\bIN\s+TXT\s+"(.+)"/', $line, $m)) { + $txt = str_replace('" "', '', $m[1]); + if (str_starts_with($txt, $start)) { + return true; + } } } return false; } - private function getCnameRecord(string $domain): ?string - { - $records = @dns_get_record($domain, \DNS_CNAME) ?: []; - - return [] !== $records ? rtrim($records[0]['target'] ?? '', '.') : null; - } - /** * @param list $errors * @param list $warnings diff --git a/src/Service/DnsCheckService.php b/src/Service/DnsCheckService.php index 01e0952..626e567 100644 --- a/src/Service/DnsCheckService.php +++ b/src/Service/DnsCheckService.php @@ -150,7 +150,7 @@ class DnsCheckService */ public function checkMx(string $domain, string $expectedMx, array &$checks, array &$errors, array &$successes): void { - $mxRecords = dns_get_record($domain, \DNS_MX) ?: []; + $mxRecords = $this->getMxRecords($domain); if ([] === $mxRecords) { $errors[] = "[$domain] MX : absent"; @@ -162,9 +162,8 @@ class DnsCheckService $found = false; $digValues = []; foreach ($mxRecords as $mx) { - $target = rtrim($mx['target'] ?? '', '.'); - $digValues[] = $target.' (pri: '.($mx['pri'] ?? '?').')'; - if (str_contains($target, $expectedMx)) { + $digValues[] = $mx['target'].' (pri: '.$mx['pri'].')'; + if (str_contains($mx['target'], $expectedMx)) { $found = true; } } @@ -189,10 +188,10 @@ class DnsCheckService { $bounceDomain = 'bounce.'.$domain; $expected = 'feedback-smtp.*.amazonses.com'; - $mxRecords = dns_get_record($bounceDomain, \DNS_MX) ?: []; + $mxRecords = $this->getMxRecords($bounceDomain); if ([] !== $mxRecords) { - $target = rtrim($mxRecords[0]['target'] ?? '', '.'); + $target = $mxRecords[0]['target']; $successes[] = "[$domain] Bounce MX : $target"; $checks[] = self::check('Bounce', 'MX '.$bounceDomain, 'ok', $target, $expected, $target); } else { @@ -207,31 +206,120 @@ class DnsCheckService } } + private const RESOLVER = '1.1.1.1'; + /** * @return list */ private function getTxtRecords(string $domain): array { - $records = dns_get_record($domain, \DNS_TXT) ?: []; + $output = $this->dig($domain, 'TXT'); + $records = []; - return array_map(fn (array $r) => $r['txt'] ?? '', $records); - } - - private function getCnameRecord(string $domain): ?string - { - $records = @dns_get_record($domain, \DNS_CNAME) ?: []; - - return [] !== $records ? rtrim($records[0]['target'] ?? '', '.') : null; - } - - private function getDkimTxtRecord(string $domain): ?string - { - foreach ($this->getTxtRecords($domain) as $record) { - if (str_contains($record, 'v=DKIM1') || str_contains($record, 'k=rsa')) { - return $record; + foreach (explode("\n", $output) as $line) { + if (preg_match('/\bIN\s+TXT\s+"(.+)"/', $line, $m)) { + $records[] = str_replace('" "', '', $m[1]); } } + return $records; + } + + public function getCnameRecord(string $domain): ?string + { + $output = $this->dig($domain, 'CNAME'); + + if (preg_match('/\bIN\s+CNAME\s+(\S+)/', $output, $m)) { + return rtrim($m[1], '.'); + } + return null; } + + /** + * @return list + */ + public function getMxRecords(string $domain): array + { + $output = $this->dig($domain, 'MX'); + $records = []; + + foreach (explode("\n", $output) as $line) { + if (preg_match('/\bIN\s+MX\s+(\d+)\s+(\S+)/', $line, $m)) { + $records[] = ['target' => rtrim($m[2], '.'), 'pri' => (int) $m[1]]; + } + } + + return $records; + } + + /** + * @return list + */ + public function getSrvRecords(string $domain): array + { + $output = $this->dig($domain, 'SRV'); + $records = []; + + foreach (explode("\n", $output) as $line) { + if (preg_match('/\bIN\s+SRV\s+(\d+)\s+\d+\s+(\d+)\s+(\S+)/', $line, $m)) { + $records[] = ['target' => rtrim($m[3], '.'), 'port' => (int) $m[2], 'priority' => (int) $m[1]]; + } + } + + return $records; + } + + private function isDigAvailable(): bool + { + static $available = null; + if (null === $available) { + $available = null !== @shell_exec('which dig 2>/dev/null'); + } + + return $available; + } + + public function dig(string $domain, string $type): string + { + if (!$this->isDigAvailable()) { + return $this->fallbackDnsGetRecord($domain, $type); + } + + $cmd = sprintf('dig @%s %s %s +noall +answer 2>/dev/null', self::RESOLVER, escapeshellarg($domain), escapeshellarg($type)); + $output = @shell_exec($cmd); + + return $output ?? ''; + } + + /** + * Fallback quand dig n'est pas installe : utilise dns_get_record() + * et formate la sortie comme dig +noall +answer. + */ + private function fallbackDnsGetRecord(string $domain, string $type): string + { + $dnsType = match (strtoupper($type)) { + 'TXT' => \DNS_TXT, + 'MX' => \DNS_MX, + 'CNAME' => \DNS_CNAME, + 'SRV' => \DNS_SRV, + default => \DNS_ANY, + }; + + $records = @dns_get_record($domain, $dnsType) ?: []; + $lines = []; + + foreach ($records as $r) { + $t = $r['type'] ?? ''; + match ($t) { + 'TXT' => $lines[] = "$domain.\tIN\tTXT\t\"".($r['txt'] ?? '')."\"", + 'MX' => $lines[] = "$domain.\tIN\tMX\t".($r['pri'] ?? 0).' '.($r['target'] ?? ''), + 'CNAME' => $lines[] = "$domain.\tIN\tCNAME\t".($r['target'] ?? ''), + 'SRV' => $lines[] = "$domain.\tIN\tSRV\t".($r['pri'] ?? 0).' '.($r['weight'] ?? 0).' '.($r['port'] ?? 0).' '.($r['target'] ?? ''), + default => null, + }; + } + + return implode("\n", $lines); + } }