feat: ajout commande app:dns:check pour verifier la configuration DNS

src/Command/CheckDnsCommand.php (nouveau fichier):
- Commande Symfony app:dns:check qui verifie les DNS de siteconseil.fr
  et esy-web.dev
- Verification SPF: presence des includes amazonses.com et mail.esy-web.dev,
  terminaison -all ou ~all
- Verification DMARC: presence sur _dmarc.*, politique p=reject/quarantine/none,
  presence de rua (adresse de rapport)
- Verification DKIM: test de 10 selecteurs (ses1/ses2/ses3, default, mail, k1,
  google, selector1/selector2, dkim) en CNAME et TXT
- Verification MX: presence de mail.esy-web.dev comme serveur de reception
- Verification Bounce: enregistrements MX/CNAME/TXT sur bounce.*,
  verification du SPF bounce avec include:amazonses.com
- Envoi d'un email de rapport complet a l'admin avec:
  - Bandeau colore vert (OK) / jaune (warnings) / rouge (erreurs)
  - Tableau des verifications reussies avec checkmarks verts
  - Tableau des erreurs en rouge avec croix
  - Tableau des avertissements en jaune avec triangles
  - Date du rapport et liste des domaines verifies
- Le rapport est envoye dans tous les cas (succes ou echec)
- Retourne FAILURE si au moins une erreur est detectee

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-02 21:22:45 +02:00
parent 2119d4be88
commit b6696df087

View File

@@ -0,0 +1,399 @@
<?php
namespace App\Command;
use App\Service\MailerService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:dns:check',
description: 'Verifie la configuration DNS (SPF, DMARC, DKIM, MX, bounce) pour siteconseil.fr et esy-web.dev',
)]
class CheckDnsCommand extends Command
{
private const DOMAINS = ['siteconseil.fr', 'esy-web.dev'];
private const EXPECTED_SPF_INCLUDES = ['amazonses.com', 'mail.esy-web.dev'];
private const EXPECTED_MX = [
'siteconseil.fr' => 'mail.esy-web.dev',
'esy-web.dev' => 'mail.esy-web.dev',
];
private const DKIM_SELECTORS = ['ses1', 'ses2', 'ses3'];
public function __construct(
private MailerService $mailer,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Verification DNS - CRM SITECONSEIL');
$errors = [];
$warnings = [];
$successes = [];
foreach (self::DOMAINS as $domain) {
$io->section('Domaine : '.$domain);
$this->checkSpf($domain, $io, $errors, $warnings, $successes);
$this->checkDmarc($domain, $io, $errors, $successes);
$this->checkDkim($domain, $io, $errors, $warnings, $successes);
$this->checkMx($domain, $io, $errors, $successes);
$this->checkBounce($domain, $io, $errors, $warnings, $successes);
}
$io->newLine();
$this->sendReportEmail($errors, $warnings, $successes);
if ([] !== $errors) {
$io->error(\count($errors).' erreur(s) detectee(s) :');
$io->listing($errors);
return Command::FAILURE;
}
if ([] !== $warnings) {
$io->warning(\count($warnings).' avertissement(s) :');
$io->listing($warnings);
}
$io->success('Tous les enregistrements DNS sont correctement configures. Rapport envoye par email.');
return Command::SUCCESS;
}
/**
* @param list<string> $errors
* @param list<string> $warnings
* @param list<string> $successes
*/
private function checkSpf(string $domain, SymfonyStyle $io, array &$errors, array &$warnings, array &$successes): void
{
$records = $this->getTxtRecords($domain);
$spf = null;
foreach ($records as $record) {
if (str_starts_with($record, 'v=spf1')) {
$spf = $record;
break;
}
}
if (null === $spf) {
$errors[] = "[$domain] SPF : aucun enregistrement SPF trouve";
$io->text(' SPF : <fg=red>ABSENT</>');
return;
}
$io->text(' SPF : '.$spf);
foreach (self::EXPECTED_SPF_INCLUDES as $include) {
if (!str_contains($spf, 'include:'.$include)) {
$errors[] = "[$domain] SPF : include:$include manquant dans l'enregistrement SPF";
$io->text(' <fg=red>MANQUANT</> include:'.$include);
} else {
$successes[] = "[$domain] SPF : include:$include present";
$io->text(' <fg=green>OK</> include:'.$include);
}
}
if (!str_contains($spf, '-all') && !str_contains($spf, '~all')) {
$warnings[] = "[$domain] SPF : pas de -all ou ~all en fin d'enregistrement";
}
}
/**
* @param list<string> $errors
* @param list<string> $successes
*/
private function checkDmarc(string $domain, SymfonyStyle $io, array &$errors, array &$successes): void
{
$records = $this->getTxtRecords('_dmarc.'.$domain);
$dmarc = null;
foreach ($records as $record) {
if (str_starts_with($record, 'v=DMARC1')) {
$dmarc = $record;
break;
}
}
if (null === $dmarc) {
$errors[] = "[$domain] DMARC : aucun enregistrement DMARC trouve sur _dmarc.$domain";
$io->text(' DMARC : <fg=red>ABSENT</>');
return;
}
$io->text(' DMARC : '.$dmarc);
if (str_contains($dmarc, 'p=none')) {
$io->text(' <fg=yellow>ATTENTION</> politique p=none (pas de protection)');
} elseif (str_contains($dmarc, 'p=quarantine')) {
$successes[] = "[$domain] DMARC : politique p=quarantine active";
$io->text(' <fg=green>OK</> politique p=quarantine');
} elseif (str_contains($dmarc, 'p=reject')) {
$successes[] = "[$domain] DMARC : politique p=reject active (stricte)";
$io->text(' <fg=green>OK</> politique p=reject (stricte)');
}
if (!str_contains($dmarc, 'rua=')) {
$errors[] = "[$domain] DMARC : pas d'adresse de rapport (rua) configuree";
} else {
$successes[] = "[$domain] DMARC : adresse de rapport (rua) configuree";
}
}
/**
* @param list<string> $errors
* @param list<string> $warnings
* @param list<string> $successes
*/
private function checkDkim(string $domain, SymfonyStyle $io, array &$errors, array &$warnings, array &$successes): void
{
$found = 0;
foreach (self::DKIM_SELECTORS as $selector) {
$cname = $this->getCnameRecord($selector.'._domainkey.'.$domain);
if (null !== $cname) {
$io->text(' DKIM ('.$selector.') : <fg=green>OK</> → '.$cname);
++$found;
}
}
// Essayer aussi les selecteurs personnalises courants
$customSelectors = ['default', 'mail', 'k1', 'google', 'selector1', 'selector2', 'dkim'];
foreach ($customSelectors as $selector) {
$cname = $this->getCnameRecord($selector.'._domainkey.'.$domain);
$txt = $this->getDkimTxtRecord($selector.'._domainkey.'.$domain);
if (null !== $cname) {
$io->text(' DKIM ('.$selector.') : <fg=green>OK</> CNAME → '.$cname);
++$found;
} elseif (null !== $txt) {
$io->text(' DKIM ('.$selector.') : <fg=green>OK</> TXT');
++$found;
}
}
if (0 === $found) {
$warnings[] = "[$domain] DKIM : aucun enregistrement DKIM trouve (selecteurs testes: ".implode(', ', array_merge(self::DKIM_SELECTORS, $customSelectors)).')';
$io->text(' DKIM : <fg=yellow>AUCUN SELECTEUR TROUVE</>');
} else {
$successes[] = "[$domain] DKIM : $found selecteur(s) DKIM trouve(s)";
}
}
/**
* @param list<string> $errors
* @param list<string> $successes
*/
private function checkMx(string $domain, SymfonyStyle $io, array &$errors, array &$successes): void
{
$mxRecords = dns_get_record($domain, \DNS_MX) ?: [];
if ([] === $mxRecords) {
$errors[] = "[$domain] MX : aucun enregistrement MX trouve";
$io->text(' MX : <fg=red>ABSENT</>');
return;
}
$expectedMx = self::EXPECTED_MX[$domain] ?? null;
foreach ($mxRecords as $mx) {
$target = rtrim($mx['target'] ?? '', '.');
$priority = $mx['pri'] ?? '?';
$isExpected = null !== $expectedMx && str_contains($target, $expectedMx);
$io->text(' MX : '.($isExpected ? '<fg=green>OK</>' : '<fg=yellow>?</>').
' '.$target.' (priorite: '.$priority.')');
}
if (null !== $expectedMx) {
$found = false;
foreach ($mxRecords as $mx) {
if (str_contains(rtrim($mx['target'] ?? '', '.'), $expectedMx)) {
$found = true;
break;
}
}
if (!$found) {
$errors[] = "[$domain] MX : $expectedMx attendu mais non trouve";
} else {
$successes[] = "[$domain] MX : $expectedMx correctement configure";
}
}
}
/**
* @param list<string> $errors
* @param list<string> $warnings
* @param list<string> $successes
*/
private function checkBounce(string $domain, SymfonyStyle $io, array &$errors, array &$warnings, array &$successes): void
{
$bounceDomain = 'bounce.'.$domain;
// Verifier MX sur bounce.*
$mxRecords = dns_get_record($bounceDomain, \DNS_MX) ?: [];
if ([] !== $mxRecords) {
foreach ($mxRecords as $mx) {
$io->text(' Bounce MX : <fg=green>OK</> '.rtrim($mx['target'] ?? '', '.').' (priorite: '.($mx['pri'] ?? '?').')');
}
} else {
// Verifier CNAME sur bounce.*
$cname = $this->getCnameRecord($bounceDomain);
if (null !== $cname) {
$io->text(' Bounce CNAME : <fg=green>OK</> → '.$cname);
} else {
// Verifier TXT sur bounce.*
$txt = $this->getTxtRecords($bounceDomain);
if ([] !== $txt) {
$io->text(' Bounce TXT : <fg=green>OK</> '.implode(' | ', $txt));
} else {
$warnings[] = "[$domain] Bounce : aucun enregistrement MX/CNAME/TXT sur $bounceDomain";
$io->text(' Bounce : <fg=yellow>AUCUN ENREGISTREMENT</> sur '.$bounceDomain);
}
}
}
// Verifier le TXT SPF sur bounce.*
$bounceTxt = $this->getTxtRecords($bounceDomain);
$bounceSpf = null;
foreach ($bounceTxt as $record) {
if (str_starts_with($record, 'v=spf1')) {
$bounceSpf = $record;
break;
}
}
if (null !== $bounceSpf) {
$io->text(' Bounce SPF : '.$bounceSpf);
if (!str_contains($bounceSpf, 'amazonses.com')) {
$warnings[] = "[$domain] Bounce SPF : include:amazonses.com manquant sur $bounceDomain";
}
}
}
/**
* @return list<string>
*/
private function getTxtRecords(string $domain): array
{
$records = dns_get_record($domain, \DNS_TXT) ?: [];
return array_map(fn (array $r) => $r['txt'] ?? '', $records);
}
private function getCnameRecord(string $domain): ?string
{
$records = @dns_get_record($domain, \DNS_CNAME) ?: [];
if ([] === $records) {
return null;
}
return rtrim($records[0]['target'] ?? '', '.');
}
private function getDkimTxtRecord(string $domain): ?string
{
$records = $this->getTxtRecords($domain);
foreach ($records as $record) {
if (str_contains($record, 'v=DKIM1') || str_contains($record, 'k=rsa')) {
return $record;
}
}
return null;
}
/**
* @param list<string> $errors
* @param list<string> $warnings
* @param list<string> $successes
*/
private function sendReportEmail(array $errors, array $warnings, array $successes): void
{
$hasErrors = [] !== $errors;
$date = (new \DateTimeImmutable())->format('d/m/Y H:i:s');
if ($hasErrors) {
$subject = 'CRM SITECONSEIL - Alerte DNS : '.\count($errors).' erreur(s) detectee(s)';
$statusColor = '#dc2626';
$statusText = 'ERREURS DETECTEES';
} elseif ([] !== $warnings) {
$subject = 'CRM SITECONSEIL - DNS : '.\count($warnings).' avertissement(s)';
$statusColor = '#f59e0b';
$statusText = 'AVERTISSEMENTS';
} else {
$subject = 'CRM SITECONSEIL - DNS : configuration OK';
$statusColor = '#16a34a';
$statusText = 'TOUT EST OK';
}
$body = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">';
$body .= '<tr><td style="background-color: '.$statusColor.'; color: #ffffff; padding-top: 12px; padding-bottom: 12px; padding-left: 16px; padding-right: 16px; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; text-align: center;">'.$statusText.'</td></tr>';
$body .= '</table>';
$body .= '<h2 style="font-size: 18px; font-weight: 700; margin-top: 16px; margin-bottom: 8px;">Rapport DNS - '.$date.'</h2>';
$body .= '<p style="font-size: 13px; color: #666; margin-top: 0; margin-bottom: 16px;">Domaines verifies : <strong>'.implode(', ', self::DOMAINS).'</strong></p>';
// Succes
if ([] !== $successes) {
$body .= '<h3 style="font-size: 14px; font-weight: 700; color: #16a34a; margin-top: 16px; margin-bottom: 8px;">Verifications reussies ('.\count($successes).') :</h3>';
$body .= '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border: 1px solid #e5e5e5; margin-bottom: 16px;">';
foreach ($successes as $i => $success) {
$bg = 0 === $i % 2 ? '#ffffff' : '#f9fafb';
$body .= '<tr><td style="padding-top: 6px; padding-bottom: 6px; padding-left: 12px; padding-right: 12px; font-size: 12px; background-color: '.$bg.'; border-bottom: 1px solid #eeeeee;">&#10003; '.$success.'</td></tr>';
}
$body .= '</table>';
}
// Erreurs
if ($hasErrors) {
$body .= '<h3 style="font-size: 14px; font-weight: 700; color: #dc2626; margin-top: 16px; margin-bottom: 8px;">Erreurs ('.\count($errors).') :</h3>';
$body .= '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border: 1px solid #fca5a5; margin-bottom: 16px;">';
foreach ($errors as $error) {
$body .= '<tr><td style="padding-top: 6px; padding-bottom: 6px; padding-left: 12px; padding-right: 12px; font-size: 12px; background-color: #fef2f2; color: #991b1b; border-bottom: 1px solid #fca5a5;">&#10007; '.$error.'</td></tr>';
}
$body .= '</table>';
}
// Warnings
if ([] !== $warnings) {
$body .= '<h3 style="font-size: 14px; font-weight: 700; color: #f59e0b; margin-top: 16px; margin-bottom: 8px;">Avertissements ('.\count($warnings).') :</h3>';
$body .= '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border: 1px solid #fde68a; margin-bottom: 16px;">';
foreach ($warnings as $warning) {
$body .= '<tr><td style="padding-top: 6px; padding-bottom: 6px; padding-left: 12px; padding-right: 12px; font-size: 12px; background-color: #fffbeb; color: #92400e; border-bottom: 1px solid #fde68a;">&#9888; '.$warning.'</td></tr>';
}
$body .= '</table>';
}
$body .= '<p style="font-size: 11px; color: #888; margin-top: 16px;">Rapport genere par la commande app:dns:check le '.$date.'</p>';
$this->mailer->sendEmail(
$this->mailer->getAdminEmail(),
$subject,
$body,
null,
null,
false,
);
}
}