Node.js: consultando o WHOIS de um domínio
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.
📦 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ê vê o created sujo virar
uma data limpa:
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
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.