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 \
qpdf \
curl \
dnsutils \
python3 \
python3-pip \
git \

View File

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

View File

@@ -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,13 +320,16 @@ class CheckDnsCommand extends Command
private function getTxtSpfValue(string $domain): string
{
$records = dns_get_record($domain, \DNS_TXT) ?: [];
foreach ($records as $r) {
$txt = $r['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;
}
}
}
return '';
}
@@ -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)) {
$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<string> $errors
* @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
{
$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<string>
*/
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
return $records;
}
public function getCnameRecord(string $domain): ?string
{
$records = @dns_get_record($domain, \DNS_CNAME) ?: [];
$output = $this->dig($domain, '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;
}
if (preg_match('/\bIN\s+CNAME\s+(\S+)/', $output, $m)) {
return rtrim($m[1], '.');
}
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);
}
}