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) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-02 21:52:46 +02:00
parent 5d47db73d4
commit 6a071ffdf2
4 changed files with 136 additions and 46 deletions

View File

@@ -14,6 +14,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
unzip \ unzip \
qpdf \ qpdf \
curl \ curl \
dnsutils \
python3 \ python3 \
python3-pip \ python3-pip \
git \ git \

View File

@@ -12,6 +12,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libfreetype-dev \ libfreetype-dev \
libmagickwand-dev \ libmagickwand-dev \
unzip \ unzip \
dnsutils \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install \ && docker-php-ext-install \

View File

@@ -70,7 +70,7 @@ class CheckDnsCommand extends Command
$this->dnsCheck->checkDmarc($domain, $checks, $errors, $successes); $this->dnsCheck->checkDmarc($domain, $checks, $errors, $successes);
$this->enrichWithCloudflare($checks, '_dmarc.'.$domain, 'DMARC', 'TXT', $cfRecords); $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->dnsCheck->checkMx($domain, self::EXPECTED_MX[$domain] ?? '', $checks, $errors, $successes);
$this->enrichWithCloudflare($checks, $domain, 'MX', 'MX', $cfRecords); $this->enrichWithCloudflare($checks, $domain, 'MX', 'MX', $cfRecords);
@@ -220,7 +220,7 @@ class CheckDnsCommand extends Command
foreach ($dkim['tokens'] as $token) { foreach ($dkim['tokens'] as $token) {
$expectedCname = $token.'.dkim.amazonses.com'; $expectedCname = $token.'.dkim.amazonses.com';
$dkimFqdn = $token.'._domainkey.'.$domain; $dkimFqdn = $token.'._domainkey.'.$domain;
$actualCname = $this->getCnameRecord($dkimFqdn); $actualCname = $this->dnsCheck->getCnameRecord($dkimFqdn);
$found = null !== $actualCname && str_contains($actualCname, 'dkim.amazonses.com'); $found = null !== $actualCname && str_contains($actualCname, 'dkim.amazonses.com');
$checks[] = DnsCheckService::check( $checks[] = DnsCheckService::check(
@@ -310,10 +310,9 @@ class CheckDnsCommand extends Command
private function getMxValues(string $domain): string private function getMxValues(string $domain): string
{ {
$records = dns_get_record($domain, \DNS_MX) ?: [];
$values = []; $values = [];
foreach ($records as $mx) { foreach ($this->dnsCheck->getMxRecords($domain) as $mx) {
$values[] = rtrim($mx['target'] ?? '', '.').' (pri: '.($mx['pri'] ?? '?').')'; $values[] = $mx['target'].' (pri: '.$mx['pri'].')';
} }
return implode(', ', $values); return implode(', ', $values);
@@ -321,11 +320,14 @@ class CheckDnsCommand extends Command
private function getTxtSpfValue(string $domain): string private function getTxtSpfValue(string $domain): string
{ {
$records = dns_get_record($domain, \DNS_TXT) ?: []; $output = $this->dnsCheck->dig($domain, 'TXT');
foreach ($records as $r) {
$txt = $r['txt'] ?? ''; foreach (explode("\n", $output) as $line) {
if (str_starts_with($txt, 'v=spf1')) { if (preg_match('/\bIN\s+TXT\s+"(.+)"/', $line, $m)) {
return $txt; $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) { return match ($type) {
'MX' => $this->checkMxExists($name, $expectedContent), 'MX' => $this->checkMxExists($name, $expectedContent),
'TXT' => $this->checkTxtContains($name, $expectedContent), 'TXT' => $this->checkTxtContains($name, $expectedContent),
'CNAME' => null !== $this->getCnameRecord($name), 'CNAME' => null !== $this->dnsCheck->getCnameRecord($name),
'SRV' => [] !== (dns_get_record($name, \DNS_SRV) ?: []), 'SRV' => [] !== $this->dnsCheck->getSrvRecords($name),
default => false, default => false,
}; };
} }
private function checkMxExists(string $name, string $target): bool private function checkMxExists(string $name, string $target): bool
{ {
foreach (dns_get_record($name, \DNS_MX) ?: [] as $mx) { foreach ($this->dnsCheck->getMxRecords($name) as $mx) {
if (str_contains(rtrim($mx['target'] ?? '', '.'), $target)) { if (str_contains($mx['target'], $target)) {
return true; return true;
} }
} }
@@ -422,22 +424,20 @@ class CheckDnsCommand extends Command
private function checkTxtContains(string $name, string $start): bool private function checkTxtContains(string $name, string $start): bool
{ {
foreach (dns_get_record($name, \DNS_TXT) ?: [] as $r) { $output = $this->dnsCheck->dig($name, 'TXT');
if (str_starts_with($r['txt'] ?? '', $start)) {
return true; 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; return false;
} }
private function getCnameRecord(string $domain): ?string
{
$records = @dns_get_record($domain, \DNS_CNAME) ?: [];
return [] !== $records ? rtrim($records[0]['target'] ?? '', '.') : null;
}
/** /**
* @param list<string> $errors * @param list<string> $errors
* @param list<string> $warnings * @param list<string> $warnings

View File

@@ -150,7 +150,7 @@ class DnsCheckService
*/ */
public function checkMx(string $domain, string $expectedMx, array &$checks, array &$errors, array &$successes): void 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) { if ([] === $mxRecords) {
$errors[] = "[$domain] MX : absent"; $errors[] = "[$domain] MX : absent";
@@ -162,9 +162,8 @@ class DnsCheckService
$found = false; $found = false;
$digValues = []; $digValues = [];
foreach ($mxRecords as $mx) { foreach ($mxRecords as $mx) {
$target = rtrim($mx['target'] ?? '', '.'); $digValues[] = $mx['target'].' (pri: '.$mx['pri'].')';
$digValues[] = $target.' (pri: '.($mx['pri'] ?? '?').')'; if (str_contains($mx['target'], $expectedMx)) {
if (str_contains($target, $expectedMx)) {
$found = true; $found = true;
} }
} }
@@ -189,10 +188,10 @@ class DnsCheckService
{ {
$bounceDomain = 'bounce.'.$domain; $bounceDomain = 'bounce.'.$domain;
$expected = 'feedback-smtp.*.amazonses.com'; $expected = 'feedback-smtp.*.amazonses.com';
$mxRecords = dns_get_record($bounceDomain, \DNS_MX) ?: []; $mxRecords = $this->getMxRecords($bounceDomain);
if ([] !== $mxRecords) { if ([] !== $mxRecords) {
$target = rtrim($mxRecords[0]['target'] ?? '', '.'); $target = $mxRecords[0]['target'];
$successes[] = "[$domain] Bounce MX : $target"; $successes[] = "[$domain] Bounce MX : $target";
$checks[] = self::check('Bounce', 'MX '.$bounceDomain, 'ok', $target, $expected, $target); $checks[] = self::check('Bounce', 'MX '.$bounceDomain, 'ok', $target, $expected, $target);
} else { } else {
@@ -207,31 +206,120 @@ class DnsCheckService
} }
} }
private const RESOLVER = '1.1.1.1';
/** /**
* @return list<string> * @return list<string>
*/ */
private function getTxtRecords(string $domain): array 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); foreach (explode("\n", $output) as $line) {
} if (preg_match('/\bIN\s+TXT\s+"(.+)"/', $line, $m)) {
$records[] = str_replace('" "', '', $m[1]);
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;
} }
} }
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 null;
} }
/**
* @return list<array{target: string, pri: int}>
*/
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<array{target: string, port: int, priority: int}>
*/
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);
}
} }