Pular para o conteúdo
Node.js

Node.js: consultando o WHOIS de um domínio

Paloma Macetko
Ilustração de um unicórnio consultando um livro de registros, com uma coruja segurando uma lupa sobre um pergaminho selado e globos de servidores ao fundo

Olá meus Unicórnios! 🦄✨

Eu precisava de uma coisa simples: saber quando um domínio expira. Só isso. Um script que recebesse meudominio.com.br e me dissesse a data de vencimento, para eu não descobrir que esqueci de renovar do jeito clássico — com o site fora do ar numa segunda-feira de manhã. 😅

O plano era de meia hora. Instala uma biblioteca de WHOIS, chama a função, lê o campo da data, pronto. E realmente funcionou de primeira… para github.com. Quando testei com um domínio brasileiro, o resultado voltou praticamente vazio. Sem data de criação, sem data de expiração, sem registrador. Nada.

Não era bug da biblioteca. Era eu que tinha assumido uma coisa que não é verdade: que existe um formato de resposta do WHOIS. Não existe. 🤯

Este artigo é o caminho todo: a consulta, as três armadilhas do registro brasileiro e o cuidado de segurança que ninguém comenta mas que você precisa ter quando deixa o usuário escolher o alvo da consulta.

Exemplos_Whois no GitHub O código completo deste artigo, com uma página que mostra a resposta crua ao lado da normalizada. github.com

📦 A consulta em si

A parte fácil primeiro. A biblioteca é a whois-json, que fala o protocolo WHOIS (que é de 1982, roda na porta 43 e é basicamente "mande um nome, receba um texto") e converte o texto de resposta em um objeto JavaScript:

npm install whois-json express

A chamada tem dois parâmetros que valem explicar:

const dados = await whois(dominio, { follow: 2, timeout: 15000 });

O follow: 2 é o que faz a diferença nos domínios .com. A consulta inicial cai no registro central da Verisign, que responde pouca coisa e diz "os dados bons estão no servidor do registrador tal". O follow manda a biblioteca seguir esse ponteiro. Sem ele, você recebe um resumo magro e acha que é só isso que existe.

E o timeout importa porque servidor WHOIS trava. Não responde erro, simplesmente não responde — e sem timeout o seu processo fica pendurado esperando para sempre.

🇧🇷 A descoberta: não existe formato padrão

Aqui está o coração do artigo. Rodei a consulta em dois domínios e coloquei as respostas cruas lado a lado. Repare nos nomes dos campos:

// github.com
{
  "domainName": "github.com",
  "registrar": "MarkMonitor, Inc.",
  "creationDate": "2007-10-09T18:20:50+0000",
  "registrarRegistrationExpirationDate": "2026-10-09T00:00:00+0000",
  "registrantOrganization": "GitHub, Inc.",
  "nameServer": "dns1.p08.nsone.net dns3.p08.nsone.net ..."
}

// uol.com.br
{
  "domain": "uol.com.br",
  "owner": "Universo Online S.A.",
  "created": "19960424 #7137 20031202 20031202",
  "expires": "20340424",
  "country": "BR BR BR",
  "nserver": "eliot.uol.com.br 172.64.52.216 borges.uol.com.br ..."
}

Isso mesmo: nenhum nome de campo em comum. Onde a ICANN diz domainName, o registro brasileiro diz domain. Onde diz creationDate, diz created. Onde diz registrantOrganization, diz owner.

A whois-json não tem culpa nenhuma. Ela faz exatamente o que promete: pega o texto Creation Date: ... e transforma em creationDate. Se o servidor escreveu created:, ela devolve created. Ela converte, não uniformiza — e essa distinção é toda a diferença.

A saída é um mapa de apelidos. Cada campo meu lista os nomes que já vi para ele, em ordem de preferência:

/**
 * Cada registro nomeia os campos como quer. O whois-json só converte
 * "Creation Date:" em `creationDate` — não uniformiza nada. Por isso cada
 * campo nosso lista os apelidos que já vimos, em ordem de preferência.
 *
 * A segunda coluna (`domain`, `created`, `expires`, `nserver`...) é o
 * vocabulário do registro brasileiro, que não segue o padrão da ICANN.
 */
const APELIDOS = {
  dominio: ['domainName', 'domain'],
  registrador: ['registrar'],
  criadoEm: ['creationDate', 'createdDate', 'created'],
  atualizadoEm: ['updatedDate', 'changed'],
  expiraEm: [
    'registryExpiryDate',
    'expiresDate',
    'registrarRegistrationExpirationDate',
    'expires',
  ],
  servidoresDns: ['nameServer', 'nameServers', 'nserver'],
  situacao: ['domainStatus', 'status'],
  titular: ['registrantOrganization', 'owner'],
  pais: ['registrantCountry', 'country'],
};

E a leitura fica trivial — o primeiro apelido preenchido vence:

/** Primeiro apelido preenchido, ou null. */
function primeiroPreenchido(dados, chaves) {
  for (const chave of chaves) {
    const valor = dados[chave];
    if (valor !== undefined && valor !== null && valor !== '') return valor;
  }
  return null;
}

Repare que o teste é !== undefined && !== null && !== '', e não um if (valor) simples. Parece preciosismo, mas um campo que chegue como "0" é um valor legítimo que o if descartaria.

📅 A data que vem com número de protocolo junto

Achei que, resolvido o nome do campo, o resto seria ler a data. Não foi. Olhe de novo o que o registro .br devolve em created:

19960424 #7137 20031202 20031202

São quatro coisas no mesmo campo: a data de registro (24/04/1996), o número do ticket que criou o domínio, e mais duas datas de eventos posteriores. Jogar isso num new Date() devolve Invalid Date, e o formato compacto AAAAMMDD também não é aceito.

A regra que funciona é ficar com o primeiro bloco de oito dígitos e tratar o resto como ruído:

/**
 * O registro .br devolve várias datas coladas no mesmo campo, junto com o
 * número do ticket: "19960424 #7137 20031202". Fica com o primeiro bloco
 * de 8 dígitos e o converte para ISO; o resto é ruído.
 */
function normalizarData(valor) {
  if (!valor) return null;
  const texto = String(valor).trim();

  const compacta = texto.match(/\b(\d{4})(\d{2})(\d{2})\b/);
  if (compacta) {
    const [, ano, mes, dia] = compacta;
    return `${ano}-${mes}-${dia}`;
  }

  // Formato ICANN: já vem em ISO, só corta a parte da hora.
  const iso = texto.match(/^(\d{4}-\d{2}-\d{2})/);
  if (iso) return iso[1];

  return texto;
}

A ordem dos dois testes não é acidental. O \b(\d{4})(\d{2})(\d{2})\b vem primeiro porque a data ICANN (2007-10-09T18:20:50+0000) tem hífens que impedem o casamento de oito dígitos seguidos — então ela cai no segundo teste e sai limpa. Se você inverter, o formato ISO passa pelo caminho errado.

🔤 Os servidores DNS com os IPs no meio

Mesma história, outro campo. O nserver do .br vem assim:

eliot.uol.com.br 172.64.52.216 borges.uol.com.br 172.64.53.68 charles.uol.com.br 172.64.52.59

É nome, IP, nome, IP — tudo numa string só, separado por espaço. Um split(' ') ingênuo devolve seis "servidores", metade deles sendo endereços IP.

/**
 * O campo de servidores DNS vem como uma string só, separada por espaço.
 * No .br cada nome vem seguido do IP ("eliot.uol.com.br 172.64.52.216"),
 * então descarta o que for IP e fica só com os nomes.
 */
function normalizarServidores(valor) {
  if (!valor) return [];
  const itens = Array.isArray(valor) ? valor : String(valor).split(/\s+/);
  const nomes = itens
    .map((item) => String(item).trim().toLowerCase())
    .filter(Boolean)
    .filter((item) => item.includes('.') && !/^\d{1,3}(\.\d{1,3}){3}$/.test(item))
    .filter((item) => !/^[0-9a-f:]+$/i.test(item) || !item.includes(':'));
  return [...new Set(nomes)];
}

O Set no fim não é enfeite: alguns registros repetem o mesmo servidor em contatos diferentes, e sem ele a lista sai com nomes duplicados.

E tem o caso do country, que no .br costuma vir "BR BR BR" — um por contato cadastrado. A regra é colapsar só quando todas as partes são iguais, para não estragar um valor legítimo que tenha espaço:

/** Remove repetições em campos que o .br devolve triplicados ("BR BR BR"). */
function normalizarTexto(valor) {
  if (!valor) return null;
  const texto = String(valor).trim();
  const partes = texto.split(/\s+/);
  if (partes.length > 1 && new Set(partes).size === 1) return partes[0];
  return texto;
}

🛡️ O cuidado que ninguém comenta

Se você só for ler um pedaço deste artigo, leia este. 🙏

Quando você expõe a consulta WHOIS num endpoint, está construindo uma função que abre uma conexão de rede para um alvo que o usuário digitou. E o seu servidor enxerga coisas que a internet não enxerga: a rede interna, o banco de dados, e — se estiver na nuvem — o endereço de metadados 169.254.169.254, que costuma entregar credenciais de acesso para quem perguntar.

A defesa é resolver o domínio antes e recusar o alvo se ele apontar para dentro:

/**
 * Resolve o host e garante que NENHUM dos IPs devolvidos é interno.
 * Precisa checar todos: um domínio pode devolver um IP público e um
 * privado na mesma resposta, e olhar só o primeiro deixa o furo aberto.
 * Devolve a lista de IPs resolvidos.
 */
async function exigirAlvoPublico(host) {
  const h = typeof host === 'string' ? host.trim().toLowerCase() : '';

  if (h === 'localhost') {
    const erro = new Error('Alvo não permitido (endereço interno).');
    erro.status = 400;
    throw erro;
  }
  if (!ehHostnameValido(h)) {
    const erro = new Error('Informe um domínio ou IP válido.');
    erro.status = 400;
    throw erro;
  }

  let enderecos;
  try {
    const resolvidos = await dns.promises.lookup(h, { all: true });
    enderecos = resolvidos.map((r) => r.address);
  } catch {
    const erro = new Error('Não foi possível resolver o host.');
    erro.status = 400;
    throw erro;
  }

  if (!enderecos.length || enderecos.some(ehIpPrivado)) {
    const erro = new Error('Alvo não permitido (endereço interno).');
    erro.status = 400;
    throw erro;
  }
  return enderecos;
}

Duas linhas aí merecem atenção, e as duas são erros que eu quase cometi:

O teste é enderecos.some(ehIpPrivado), não ehIpPrivado(enderecos[0]). Um domínio pode devolver vários IPs na mesma resposta — um público e um privado. Quem olha só o primeiro deixa a porta aberta para quem controla o DNS do domínio que está consultando.

O localhost é barrado por nome, antes de resolver. Ele não casa com nenhuma regra de IP porque não é um IP, e dependendo do sistema resolve para ::1 ou para 127.0.0.1.

E dentro do ehIpPrivado mora o detalhe mais cruel de todos — o IPv6 que embrulha um IPv4:

  // IPv6 que embrulha um IPv4 (::ffff:127.0.0.1) é avaliado como IPv4 —
  // sem esta linha, o loopback entra disfarçado.
  const embrulhado = endereco.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
  if (embrulhado) endereco = embrulhado[1];

O endereço ::ffff:127.0.0.1 é o loopback escrito em notação IPv6. Sem essa conversão, ele não casa com nenhuma das faixas IPv4 (porque é uma string com dois-pontos) e não casa com as regras IPv6 (porque não começa com fe80, fc ou ff) — e passa direto. Confirmei os dois casos rodando:

localhost                        -> bloqueado: Alvo não permitido (endereço interno).
127.0.0.1                        -> bloqueado: Alvo não permitido (endereço interno).
192.168.0.1                      -> bloqueado: Alvo não permitido (endereço interno).
169.254.169.254                  -> bloqueado: Alvo não permitido (endereço interno).
nao-existe-mesmo-xyz123.com.br   -> bloqueado: Não foi possível resolver o host.
--- ::ffff:127.0.0.1 privado? true
--- 8.8.8.8 privado? false

🧩 Juntando tudo

Com as peças no lugar, a função principal fica curta — resolve o alvo, consulta, normaliza:

/** Consulta o WHOIS de um domínio e devolve os dados já normalizados. */
async function consultarWhois(alvo) {
  const t = String(alvo || '').trim();
  if (!ehHostnameValido(t)) {
    const erro = new Error('Informe um domínio ou IP válido.');
    erro.status = 400;
    throw erro;
  }

  // Resolve o domínio e recusa qualquer alvo interno antes de sair da máquina.
  const enderecos = await exigirAlvoPublico(t);
  const ip = enderecos[0] || null;

  // `follow: 2` segue o WHOIS do registrador depois do registro central —
  // é onde moram os dados bons nos domínios .com.
  let dados = {};
  try {
    dados = await whois(t, { follow: 2, timeout: 15000 });
  } catch {
    dados = {}; // o alvo existe; só o servidor WHOIS não respondeu.
  }

  const registro = normalizarWhois(dados);
  const expiraEmDias = diasAte(registro.expiraEm);

  return {
    alvo: t,
    ip,
    bruto: dados, // a resposta como o servidor WHOIS mandou, sem tratamento
    registro,
    expiraEmDias,
    relatorio: montarRelatorio(t, ip, registro, expiraEmDias),
  };
}

O catch vazio ali é deliberado, e demorei para aceitar que era o certo. Se o assertPublicTarget passou, o domínio existe e resolve. Um erro depois disso significa que o servidor WHOIS não respondeu — não que o domínio seja inválido. Derrubar a requisição inteira por causa disso seria mentir sobre o que aconteceu: melhor devolver o IP que resolvemos e os campos vazios.

▶️ Rodando

O resultado nos dois domínios que abriram o artigo. Primeiro o brasileiro, que era o que voltava vazio:

WHOIS de uol.com.br
IP: 2804:49c:3102:401:ffff:ffff:ffff:36

Domínio: uol.com.br
Registrado em: 1996-04-24
Atualizado em: 2024-08-27
Expira em: 2034-04-24
Servidores DNS: eliot.uol.com.br, borges.uol.com.br, charles.uol.com.br
Situação: published
Titular: Universo Online S.A.
País: BR
Faltam 2818 dias para vencer.

De um campo preenchido para nove. 🎉 E o github.com, que já funcionava, continua funcionando pelo mesmo caminho:

WHOIS de github.com
IP: 4.228.31.150

Domínio: github.com
Registrador: MarkMonitor, Inc.
Registrado em: 2007-10-09
Atualizado em: 2024-09-07
Expira em: 2026-10-09
Servidores DNS: dns3.p08.nsone.net, dns2.p08.nsone.net, ns-1283.awsdns-32.org, dns4.p08.nsone.net, dns1.p08.nsone.net, ns-421.awsdns-52.com, ns-1707.awsdns-21.co.uk, ns-520.awsdns-01.net
Situação: clientUpdateProhibited (https://www.icann.org/epp#clientUpdateProhibited) clientTransferProhibited (https://www.icann.org/epp#clientTransferProhibited) clientDeleteProhibited (https://www.icann.org/epp#clientDeleteProhibited)
Titular: GitHub, Inc.
País: US
Faltam 64 dias para vencer.

Aquele "faltam 64 dias" é exatamente o número que eu queria no começo de tudo. 😄 (A ordem dos servidores DNS muda de uma consulta para outra — o registro devolve a lista sem ordem definida. Se for comparar duas execuções, ordene antes.)

🐛 O bug de brinde, que não era do WHOIS

Montei uma página para ver a resposta crua ao lado da normalizada. Abri no navegador, cliquei em Consultar, e não aconteceu nada. Nada mesmo: nenhum erro no console, o botão nem piscou. A API respondia perfeitamente pelo curl, então o problema estava no JavaScript da página — só que o console estava limpo.

A causa: um elemento com id="form" faz o navegador criar uma variável global window.form sozinho. E o meu script começava com const form = document.getElementById('form').

Uncaught SyntaxError: Identifier 'form' has already been declared

É um erro de parse, não de execução. O navegador rejeita o script inteiro antes de rodar a primeira linha — por isso nem um console.log no topo aparecia. A página continua desenhada, o botão continua clicável, e simplesmente nada está ligado nele.

A correção foi renomear tudo para elForm, elCampo, elBotao. Deixei o comentário no código para não repetir:

// Os nomes levam "el" na frente de propósito: um elemento com id="form"
// vira window.form sozinho, e um `const form` aqui em cima colide com ele
// — SyntaxError de parse, que mata o script inteiro sem mostrar nada.
const elForm = document.getElementById('form');

No repositório dá para ver o bug acontecer de propósito: renomeie elForm de volta para form e recarregue.

📚 O código completo

Está tudo no repositório, incluindo a página que mostra as duas respostas lado a lado — que é a melhor forma de entender o problema dos nomes de campo, porque você o created sujo virar uma data limpa:

Exemplos_Whois no GitHub whois.js, guarda.js, o servidor Express e a página de demonstração. github.com

O que eu tirei de tudo isso: quando um formato "padrão" tem mais de um registro implementando, ele não é um padrão — é uma sugestão. E a hora de descobrir isso é escrevendo o normalizador, não em produção. 🙂

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

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

Leia também