Pular para o conteúdo
Node.js

Node.js: checando registros DNS A, MX, TXT e SPF

Paloma Macetko
Ilustração colorida de um unicórnio de crina arco-íris diante de um grande livro de registros com runas em colunas, corujas mensageiras voando em fila com envelopes selados e uma varinha conferindo o selo de um deles

Olá meus Unicórnios! 🦄✨

Sabe aquele cliente que liga dizendo "meus e-mails não chegam em ninguém"? 😅 Pois é. E aí você abre o painel do DNS, olha para aquela sopa de letrinhas — A, MX, TXT, SPF, CNAME — e tem que decidir qual das cinco está errada.

Com um domínio, você resolve na unha. Com 431 domínios, não. E era exatamente esse o meu número: uma lista de domínios de clientes, todos apontando (ou deveriam apontar) para o mesmo servidor de e-mail, e a pergunta "quais desses estão configurados errado?".

Este artigo é o script que eu escrevi para responder isso. Vamos consultar A, MX, TXT e SPF com Node.js puro — zero dependência, só o módulo dns que já vem instalado. E, mais importante que o código: vou contar onde o DNS me enganou, porque foram três lugares, e nenhum deles está na documentação. 🙈

Exemplos_DNSChecar no GitHubOs dois scripts deste artigo, prontos para rodar — sem npm install, só o módulo dns do Node.github.com

📋 O que cada registro responde

Antes do código, vale alinhar o que a gente está perguntando. São quatro consultas, e cada uma responde uma pergunta diferente sobre o domínio:

A — "qual é o IP deste nome?" É o registro que faz o site abrir. Um domínio pode ter vários A, e isso é normal: é assim que CDN e balanceamento funcionam.

MX (Mail eXchanger) — "quem recebe e-mail para este domínio?" Cada MX tem uma prioridade, e aqui vem o detalhe que confunde todo mundo: número menor é tentado primeiro. Prioridade 5 é mais importante que prioridade 40. Sim, é ao contrário da intuição. 🙃

TXT — "texto livre sobre este domínio". É o registro-curinga do DNS: cabe qualquer coisa. Verificação de propriedade do Google, chave do DKIM, política do DMARC — e o SPF.

SPF (Sender Policy Framework) — "quem tem permissão de enviar e-mail em nome deste domínio?" Repare: SPF não é um tipo de registro. É um TXT que começa com v=spf1. Existiu um tipo SPF dedicado (RFC 4408), e ele foi oficialmente aposentado em 2014 pela RFC 7208. Se você procurar por tipo, não acha nada.

Ou seja: MX diz por onde o e-mail entra, SPF diz por onde ele pode sair. São perguntas independentes, e é comum um estar certo e o outro errado.

🔌 O primeiro susto: o resolver do sistema

Meu script original não começava com isso. Começava direto no dns.resolveMx() — e morria com ECONNREFUSED em domínios que eu sabia que existiam.

O módulo dns do Node, por padrão, usa o resolver que o sistema operacional configurou. E o que o sistema configurou, na minha máquina, era o roteador da operadora. Que às vezes responde, às vezes não, e às vezes responde errado — filtrando, cacheando por tempo demais ou devolvendo página de busca para nome inexistente.

A primeira linha útil do script é essa:

const dns = require('dns').promises;

// Resolver publico: o do sistema pode estar sequestrado pelo roteador
require('dns').setServers(['8.8.8.8', '8.8.4.4', '1.1.1.1']);

Isso força o Node a falar direto com o Google DNS e o Cloudflare, ignorando o do sistema. Dois efeitos práticos: os ECONNREFUSED sumiram, e o resultado passou a ser reproduzível — o mesmo script na minha máquina e no servidor devolve a mesma coisa.

🦉 A ordem dos MX é uma mentira

Esta foi a que me pegou de verdade. 😳

Eu li os MX, imprimi a lista e mandei para o cliente. Ele respondeu perguntando por que o servidor principal estava "em quarto lugar". Eu tinha assumido que o resolver devolve os registros em ordem de prioridade. Ele não devolve. Nem promete devolver.

Olhe a saída real desse domínio, exatamente como o resolver entregou:

palomamacetko.com.br	MX preference = 35, mail exchanger = mail7.palomamacetko.com.br
palomamacetko.com.br	MX preference = 25, mail exchanger = mail4.palomamacetko.com.br
palomamacetko.com.br	MX preference = 25, mail exchanger = mail5.palomamacetko.com.br
palomamacetko.com.br	MX preference = 30, mail exchanger = mail6.palomamacetko.com.br
palomamacetko.com.br	MX preference = 5,  mail exchanger = mail.palomamacetko.com.br
palomamacetko.com.br	MX preference = 40, mail exchanger = mail8.palomamacetko.com.br
palomamacetko.com.br	MX preference = 10, mail exchanger = mail2.palomamacetko.com.br
palomamacetko.com.br	MX preference = 20, mail exchanger = mail3.palomamacetko.com.br

35, 25, 25, 30, 5, 40, 10, 20. O servidor principal — prioridade 5, o que recebe tudo na prática — apareceu na quinta linha. 🤯

E não é bug do resolver: é o comportamento correto. Servidores DNS costumam embaralhar registros do mesmo tipo de propósito, para distribuir carga (o famoso round-robin). Quem tem que ordenar é quem consome:

const { valor } = await consultar('resolveMx', 'palomamacetko.com.br');

// Prioridade menor = tentado primeiro. O resolver nao ordena
const registrosMx = valor
  .sort((x, y) => x.priority - y.priority)
  .map((r) => `${r.priority} ${r.exchange}`);

Esse sort de uma linha é a diferença entre um relatório que se lê e um relatório que engana. Se você só for copiar um pedaço deste artigo, copie esse. 🙏

✂️ O TXT que vem cortado em pedaços

Agora a segunda armadilha, e essa é sutil o suficiente para passar meses sem ninguém notar.

Um único registro TXT no DNS não pode ter uma string maior que 255 bytes — é limite do protocolo. Registros mais longos que isso são gravados como várias strings concatenadas. E o Node te entrega exatamente assim: resolveTxt() devolve um array de arrays.

O array de fora é "um item por registro TXT". O array de dentro é "um item por pedaço de 255 bytes daquele registro". Por isso:

const { valor } = await consultar('resolveTxt', dominio);

// Cada TXT vem em pedacos de 255 bytes: juntar antes de olhar
const textos = valor.map((pedacos) => pedacos.join(''));
const spf = textos.filter((t) => t.toLowerCase().startsWith('v=spf1'));

Repare no pedacos.join('') — sem separador nenhum. É colar de volta, não juntar com espaço.

E por que isso importa tanto? Porque SPF longo é justamente o que estoura 255 bytes. Um SPF com quatro ou cinco include: passa fácil. Se você esquecer o join e testar só com v=spf1 a mx ~all (que é curtinho), funciona perfeitamente — e vai falhar silenciosamente no primeiro cliente que tem SPF de verdade. O startsWith('v=spf1') ainda acha, porque o começo está no primeiro pedaço; o que você perde é o resto da política, sem erro nenhum. 😬

Olhe o tamanho deste SPF real, de um cliente que usa SendGrid e Google Workspace juntos:

v=spf1 include:sendgrid.net include:_spf.google.com
include:spf.cliente.palomamacetko.com.br a mx
ip4:203.0.113.60/32 ip4:203.0.113.61/32
ip4:203.0.113.0/27 ~all

São 176 bytes. Ainda passa. Mas basta o cliente contratar mais um serviço de e-mail marketing e ele estoura — e o script que "estava funcionando" começa a mentir.

🚨 Dois SPF no mesmo domínio: o erro que parece certo

Esta é a joia do relatório, e o motivo de eu ter escrito o script em vez de conferir na mão. Olhe a saída real:

== duplicado.palomamacetko.com.br
   A   : 203.0.113.20 | 203.0.113.21
   MX  : 5 mail.palomamacetko.com.br | 10 mail2.palomamacetko.com.br | 15 mail3.palomamacetko.com.br | 20 mail4.palomamacetko.com.br | 25 mail5.palomamacetko.com.br | 30 mail6.palomamacetko.com.br | 35 mail7.palomamacetko.com.br | 40 mail8.palomamacetko.com.br
   SPF : v=spf1 a mx ~all
   SPF : v=spf1 include:servers.mcsv.net ?all
   !!! 2 SPF no mesmo dominio -> permerror

Dois registros SPF no mesmo domínio. E cada um deles, olhado sozinho, está perfeitamente válido: o primeiro autoriza o servidor da hospedagem, o segundo autoriza o Mailchimp. É óbvio o que aconteceu — alguém precisou liberar o Mailchimp e adicionou um TXT em vez de editar o que já existia. Faz todo sentido do ponto de vista de quem clicou.

Só que o SPF não funciona assim. A RFC 7208 é explícita: mais de um registro v=spf1 no mesmo domínio é PermError. Não é "vale o primeiro", não é "vale a união dos dois" — é erro permanente, e o receptor deve tratar como se a política estivesse quebrada.

Resultado prático: ao tentar liberar o Mailchimp, o cliente invalidou o SPF inteiro. Ficou pior do que estava antes de mexer. E nenhum painel de DNS avisa — os dois registros aparecem lá, verdinhos, cada um com sua sintaxe correta. 🙈

Por isso o script não conta apenas "achei SPF": ele conta quantos.

r.spf.forEach((s) => linhas.push(`   SPF : ${s}`));

// Dois SPF = permerror, nao "dois validos"
if (r.spf.length > 1) {
  linhas.push(`   !!! ${r.spf.length} SPF no mesmo dominio -> permerror`);
}

De 431 domínios, esse padrão apareceu em alguns — e cada um era um cliente que, em algum momento, ia reclamar que "o e-mail marketing não chega". A solução, quando aparece, é sempre a mesma: apagar os dois e criar um só, fundindo os mecanismos. Neste caso, v=spf1 a mx include:servers.mcsv.net ~all.

🕵️ ENOTFOUND e ENODATA não são a mesma coisa

Aqui está a diferença que transforma um relatório em diagnóstico. As duas linhas abaixo são saída real, e parecem dizer a mesma coisa:

== clinica.palomamacetko.com.br
   A   : 203.0.113.42 | 203.0.113.40 | 203.0.113.41
   MX  : 10 mail.clinica.palomamacetko.com.br | 20 mail3.palomamacetko.com.br | 20 mail4.palomamacetko.com.br
   SPF : ausente (ENODATA)

== naoexiste.palomamacetko.com.br
   A   : nenhum (ENOTFOUND)
   MX  : nenhum (ENOTFOUND)
   SPF : ausente (ENOTFOUND)

Não dizem. E a distinção vale ouro no atendimento:

ENOTFOUND — o nome não existe no DNS. É o NXDOMAIN do protocolo. Domínio expirado, nunca registrado, ou erro de digitação. Repare que o naoexiste.palomamacetko.com.br deu ENOTFOUND nas três consultas: quando o nome não existe, nada existe.

ENODATA — o nome existe, mas não tem registro daquele tipo. O caso do clinica.palomamacetko.com.br é exemplar: o domínio está no ar (tem A, tem MX), mas não tem SPF nenhum. É um domínio saudável com um problema específico e acionável.

Confundir os dois é dizer "seu domínio não existe" para um cliente cujo site está funcionando. 😅 Por isso a função de consulta devolve lista vazia mais o err.code — em vez de estourar, ou de engolir o motivo:

// Devolve [] em vez de estourar, guardando o codigo do erro.
// ENOTFOUND = nome nao existe; ENODATA = existe, sem esse tipo
async function consultar(tipo, nome) {
  try {
    return { valor: await dns[tipo](nome), erro: null };
  } catch (err) {
    return { valor: [], erro: err.code || err.message };
  }
}

Sem isso, "sem MX" e "domínio morto" viram a mesma linha no relatório — e você perde a informação mais útil que a consulta te deu.

🎯 Validar antes de consultar (a lista suja)

Uma coisa que ninguém conta sobre processar listas de domínios do mundo real: a lista está suja. Sempre. A minha, exportada do painel de clientes, tinha coisas assim:

[email protected]          <- e-mail, nao dominio
antigo.palomamacetko.com.br_desativado <- sufixo de controle
loja.palomamacetko.com.br,loja.palomamacetko.com   <- dois na mesma linha
THECENTS                            <- nome sem TLD
PALOMAMACETKO.COM.BR                <- maiusculas

Cada uma dessas viraria uma consulta DNS inútil — e, pior, uma linha de ENOTFOUND no relatório final, indistinguível de um domínio de verdade que expirou. O ruído esconde o sinal.

Então filtramos antes de consultar:

// Aceita dominio nu. Rejeita e-mail, URL e nome sem TLD
const VALIDO = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/;

if (!VALIDO.test(dominio)) return { dominio, invalido: true };

Essa regex faz três coisas que a versão ingênua (/^[a-z0-9._-]+\.[a-z]{2,}$/, que era a minha primeira) não fazia: exige pelo menos um ponto seguido de TLD alfabético (mata THECENTS), rejeita @ e _ (mata o e-mail e o _desativado), e barra rótulo começando ou terminando com hífen, que é inválido pela RFC 1035. Deixei fora da função de propósito: regex declarada dentro de função é recompilada a cada chamada, e essa roda 431 vezes.

E o resultado no relatório é honesto — o item aparece marcado, não escondido:

== [email protected]
   dominio invalido (nao consultado)

Já a linha com vírgula é caso de parsing, não de validação: flatMap(l => l.split(',')) antes do filtro resolve, e você ganha dois domínios em vez de perder um.

⚡ Quatro consultas por domínio, sem esperar em fila

Agora a parte de desempenho, que com 431 domínios deixa de ser detalhe.

Consulta DNS é espera de rede: cada uma leva de 20 a 200 ms, e o processador fica de braços cruzados. Fazer as três em sequência para o mesmo domínio é somar três esperas à toa, já que elas não dependem uma da outra:

const [a, mx, txt] = await Promise.all([
  consultar('resolve4', dominio),
  consultar('resolveMx', dominio),
  consultar('resolveTxt', dominio),
]);

Aí vem a tentação: se paralelizar três é bom, paralelizar 431 domínios × 3 consultas de uma vez deve ser ótimo, né? 😅 Não. Mil e trezentas consultas simultâneas contra o mesmo resolver é o caminho mais curto para levar rate limit — e o sintoma é cruel: o Google DNS começa a devolver SERVFAIL ou timeout, e o seu relatório enche de erros que não são problema dos domínios. Você vai olhar aquilo e "descobrir" 80 clientes com DNS quebrado que estão perfeitamente bem.

A solução é processar em lotes:

// Em lotes: 400 dominios de uma vez levam rate limit do resolver
const LOTE = 8;
for (let i = 0; i < alvos.length; i += LOTE) {
  const resultados = await Promise.all(alvos.slice(i, i + LOTE).map(checar));
  resultados.forEach((r) => console.log(`${formatar(r)}
`));
}

Oito domínios por vez — 24 consultas simultâneas. Os 431 domínios saem em cerca de um minuto, sem um único erro de rate limit. Nos scripts originais eu usava lotes de 5 e de 10; qualquer coisa nessa faixa funciona. O que não funciona é o infinito.

📜 O script completo

Junta tudo o que a gente discutiu. Salve como checar-dns.js, sem npm install nenhum — o módulo dns já vem no Node:

const dns = require('dns').promises;

// Resolver publico: o do sistema pode estar sequestrado pelo roteador
require('dns').setServers(['8.8.8.8', '8.8.4.4', '1.1.1.1']);

// Aceita dominio nu. Rejeita e-mail, URL e nome sem TLD
const VALIDO = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/;

// Devolve [] em vez de estourar, guardando o codigo do erro.
// ENOTFOUND = nome nao existe; ENODATA = existe, sem esse tipo
async function consultar(tipo, nome) {
  try {
    return { valor: await dns[tipo](nome), erro: null };
  } catch (err) {
    return { valor: [], erro: err.code || err.message };
  }
}

async function checar(dominio) {
  if (!VALIDO.test(dominio)) return { dominio, invalido: true };

  const [a, mx, txt] = await Promise.all([
    consultar('resolve4', dominio),
    consultar('resolveMx', dominio),
    consultar('resolveTxt', dominio),
  ]);

  // Prioridade menor = tentado primeiro. O resolver nao ordena
  const registrosMx = mx.valor
    .sort((x, y) => x.priority - y.priority)
    .map((r) => `${r.priority} ${r.exchange}`);

  // Cada TXT vem em pedacos de 255 bytes: juntar antes de olhar
  const textos = txt.valor.map((pedacos) => pedacos.join(''));
  const spf = textos.filter((t) => t.toLowerCase().startsWith('v=spf1'));

  return {
    dominio,
    a: a.valor,
    mx: registrosMx,
    spf,
    outrosTxt: textos.length - spf.length,
    erros: { a: a.erro, mx: mx.erro, txt: txt.erro },
  };
}

function formatar(r) {
  if (r.invalido) return `== ${r.dominio}\n   dominio invalido (nao consultado)`;

  const lista = (itens, erro) => (itens.length ? itens.join(' | ') : `nenhum (${erro})`);
  const linhas = [
    `== ${r.dominio}`,
    `   A   : ${lista(r.a, r.erros.a)}`,
    `   MX  : ${lista(r.mx, r.erros.mx)}`,
  ];

  if (r.spf.length) {
    r.spf.forEach((s) => linhas.push(`   SPF : ${s}`));
    // Dois SPF = permerror, nao "dois validos"
    if (r.spf.length > 1) {
      linhas.push(`   !!! ${r.spf.length} SPF no mesmo dominio -> permerror`);
    }
  } else {
    linhas.push(`   SPF : ausente${r.erros.txt ? ` (${r.erros.txt})` : ''}`);
  }

  if (r.outrosTxt) linhas.push(`   TXT : ${r.outrosTxt} outro(s) registro(s)`);

  return linhas.join('\n');
}

async function main() {
  const alvos = process.argv.slice(2).map((d) => d.trim().toLowerCase());
  if (!alvos.length) {
    console.error('uso: node checar-dns.js dominio1 dominio2 ...');
    process.exit(1);
  }

  console.log(`Checando ${alvos.length} dominio(s)...\n`);

  // Em lotes: 400 dominios de uma vez levam rate limit do resolver
  const LOTE = 8;
  for (let i = 0; i < alvos.length; i += LOTE) {
    const resultados = await Promise.all(alvos.slice(i, i + LOTE).map(checar));
    resultados.forEach((r) => console.log(`${formatar(r)}\n`));
  }
}

main();

Uso:

node checar-dns.js palomamacetko.com.br duplicado.palomamacetko.com.br blog.palomamacetko.com.br

E a saída real dessa chamada, com os três domínios que ilustram os casos do artigo:

Checando 3 dominio(s)...

== palomamacetko.com.br
   A   : 203.0.113.11 | 203.0.113.10
   MX  : 5 mail.palomamacetko.com.br | 10 mail2.palomamacetko.com.br | 20 mail3.palomamacetko.com.br | 25 mail4.palomamacetko.com.br | 25 mail5.palomamacetko.com.br | 30 mail6.palomamacetko.com.br | 35 mail7.palomamacetko.com.br | 40 mail8.palomamacetko.com.br
   SPF : v=spf1 include:amazonses.com a mx ~all
   TXT : 2 outro(s) registro(s)

== duplicado.palomamacetko.com.br
   A   : 203.0.113.20 | 203.0.113.21
   MX  : 5 mail.palomamacetko.com.br | 10 mail2.palomamacetko.com.br | 15 mail3.palomamacetko.com.br | 20 mail4.palomamacetko.com.br | 25 mail5.palomamacetko.com.br | 30 mail6.palomamacetko.com.br | 35 mail7.palomamacetko.com.br | 40 mail8.palomamacetko.com.br
   SPF : v=spf1 a mx ~all
   SPF : v=spf1 include:servers.mcsv.net ?all
   !!! 2 SPF no mesmo dominio -> permerror

== blog.palomamacetko.com.br
   A   : 203.0.113.30 | 203.0.113.31
   MX  : nenhum (ENODATA)
   SPF : ausente (ENODATA)

Repare na última: o blog não tem MX nem SPF, e está certo assim — ele não recebe nem envia e-mail. ENODATA aqui é a resposta correta, não um defeito. Ferramenta boa não confunde ausência esperada com problema; ela te dá o dado e deixa você decidir.

🔍 Bônus: o MX que aponta para o lugar certo (mas errado)

Tem um caso que só apareceu porque eu cruzei duas consultas, e é o mais escorregadio de todos.

Olhe este domínio, que passa em qualquer verificação de MX:

== loja.palomamacetko.com.br
   MX  : 10 mail.loja.palomamacetko.com.br | 10 mail2.loja.palomamacetko.com.br
       | 20 mail3.palomamacetko.com.br | 20 mail4.palomamacetko.com.br
   A de mail.loja.palomamacetko.com.br: 203.0.113.50

Os dois MX de prioridade 10 apontam para mail.loja.… e mail2.loja.… — nomes sob o domínio do próprio cliente, e não os do servidor (mail3. e mail4., que aparecem logo abaixo com prioridade 20). Parece configuração personalizada e sofisticada.

Só que esses nomes resolvem para 203.0.113.50, que é exatamente o IP do servidor compartilhado. É o mesmo destino, com um apelido no meio.

Por que isso é um problema? Porque agora existe um ponto de falha extra. No dia em que o servidor mudar de IP, os domínios que apontam para mail.palomamacetko.com.br seguem funcionando — o administrador muda um A e pronto. Os que apontam para mail.loja.palomamacetko.com.br param, e ninguém vai lembrar que eles existem, porque o MX continua parecendo perfeito.

Achar isso exige justamente cruzar as duas consultas: resolver o MX, e depois resolver o A de cada MX, comparando com a lista de IPs que você sabe que são do servidor. É o que o checar-mail.js do repositório faz, e é o tipo de conferência que nenhuma ferramenta online faz por você — porque só você sabe qual IP era o esperado. 😉

✅ O que eu levo desse script

Quatro linhas de conclusão, para quem vai escrever a própria versão:

1. Ordene os MX. O resolver embaralha de propósito, e um relatório desordenado engana quem confia nele.

2. Junte os pedaços do TXT. Sem o join(''), o SPF longo é truncado sem erro nenhum — o bug mais silencioso da lista.

3. Conte os SPF, não só ache um. Dois registros válidos somam um PermError, e é o erro mais comum que eu encontrei.

4. Guarde o código do erro. ENODATA é "falta configurar", ENOTFOUND é "domínio morto". São conversas completamente diferentes com o cliente.

E a lição mais geral, que valeu mais que o script: com volume, o que você precisa não é de uma resposta — é de um relatório que separa o normal do anormal. Conferir 431 domínios à mão não é só demorado; é impossível de fazer com atenção constante. Na décima aba do navegador você já não está mais olhando de verdade. 😴

O código que eu usei nasceu de quatro scripts diferentes, cada um respondendo uma pergunta nova que a resposta anterior levantou. Destilei os dois que importam num repositório, e foi ele que virou este artigo.

💾 O repositório

Os dois scripts estão lá inteiros, do jeito que você leu aqui — e o README traz as seis armadilhas resumidas, com a prova real do TXT cortado em pedaços:

Exemplos_DNSChecar no GitHubchecar-dns.js e checar-mail.js, sem dependência nenhuma. É só clonar e rodar.github.com

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também