O DNS (Domain Name System) e o servico que traduz nomes legíveis como www.exemplo.com em enderecos IP como 93.184.216.34. Embora usemos DNS o tempo todo, poucos desenvolvedores entendem como o protocolo funciona por dentro. Neste projeto, vamos construir um resolver DNS do zero, montando os pacotes binarios manualmente, enviando-os via UDP e interpretando as respostas.
Este e um excelente exercicio para entender protocolos de rede, manipulacao de bytes e comunicacao UDP – habilidades fundamentais para qualquer programador de sistemas.
O Que Vamos Construir
- Construcao manual de queries DNS (formato binario)
- Envio de consultas via UDP para servidores DNS
- Parsing das respostas DNS com registros A, AAAA, CNAME e MX
- Suporte a diferentes tipos de consulta
- Exibicao formatada dos resultados
- Tratamento de erros e timeouts
Estrutura do Projeto
dns-resolver/
├── Cargo.toml
└── src/
├── main.rs
├── protocolo.rs
├── construtor.rs
└── parser.rs
Configurando o Projeto
cargo new dns-resolver
cd dns-resolver
Edite o Cargo.toml:
[package]
name = "dns-resolver"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.5", features = ["derive"] }
colored = "2.1"
rand = "0.8"
Neste projeto nao usamos crates de DNS – implementamos o protocolo manualmente para fins educativos.
Passo 1: Definicoes do Protocolo DNS
O modulo de protocolo define as estruturas e constantes do DNS. Crie src/protocolo.rs:
/// Tipos de registro DNS suportados.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TipoRegistro {
A = 1, // Endereco IPv4
AAAA = 28, // Endereco IPv6
CNAME = 5, // Nome canonico
MX = 15, // Mail exchange
NS = 2, // Servidor de nomes
TXT = 16, // Texto
}
impl TipoRegistro {
pub fn from_u16(valor: u16) -> Option<Self> {
match valor {
1 => Some(TipoRegistro::A),
28 => Some(TipoRegistro::AAAA),
5 => Some(TipoRegistro::CNAME),
15 => Some(TipoRegistro::MX),
2 => Some(TipoRegistro::NS),
16 => Some(TipoRegistro::TXT),
_ => None,
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s.to_uppercase().as_str() {
"A" => Some(TipoRegistro::A),
"AAAA" => Some(TipoRegistro::AAAA),
"CNAME" => Some(TipoRegistro::CNAME),
"MX" => Some(TipoRegistro::MX),
"NS" => Some(TipoRegistro::NS),
"TXT" => Some(TipoRegistro::TXT),
_ => None,
}
}
pub fn nome(&self) -> &'static str {
match self {
TipoRegistro::A => "A",
TipoRegistro::AAAA => "AAAA",
TipoRegistro::CNAME => "CNAME",
TipoRegistro::MX => "MX",
TipoRegistro::NS => "NS",
TipoRegistro::TXT => "TXT",
}
}
}
/// Cabecalho de um pacote DNS (12 bytes).
#[derive(Debug)]
pub struct CabecalhoDns {
pub id: u16,
pub flags: u16,
pub contagem_perguntas: u16,
pub contagem_respostas: u16,
pub contagem_autoridade: u16,
pub contagem_adicional: u16,
}
/// Um registro de resposta DNS.
#[derive(Debug)]
pub struct RegistroResposta {
pub nome: String,
pub tipo_registro: u16,
pub classe: u16,
pub ttl: u32,
pub dados: DadosRegistro,
}
/// Dados especificos de cada tipo de registro.
#[derive(Debug)]
pub enum DadosRegistro {
A([u8; 4]),
AAAA([u8; 16]),
CNAME(String),
MX { prioridade: u16, servidor: String },
NS(String),
TXT(String),
Desconhecido(Vec<u8>),
}
impl std::fmt::Display for DadosRegistro {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DadosRegistro::A(ip) => {
write!(f, "{}.{}.{}.{}", ip[0], ip[1], ip[2], ip[3])
}
DadosRegistro::AAAA(ip) => {
let partes: Vec<String> = ip
.chunks(2)
.map(|c| format!("{:02x}{:02x}", c[0], c[1]))
.collect();
write!(f, "{}", partes.join(":"))
}
DadosRegistro::CNAME(nome) => write!(f, "{}", nome),
DadosRegistro::MX {
prioridade,
servidor,
} => write!(f, "{} {}", prioridade, servidor),
DadosRegistro::NS(nome) => write!(f, "{}", nome),
DadosRegistro::TXT(texto) => write!(f, "\"{}\"", texto),
DadosRegistro::Desconhecido(bytes) => {
write!(f, "<{} bytes>", bytes.len())
}
}
}
}
O protocolo DNS usa formato binario compacto. Cada tipo de registro carrega dados diferentes: registros A contem 4 bytes de IPv4, AAAA contem 16 bytes de IPv6, e assim por diante.
Passo 2: Construtor de Queries
O construtor monta o pacote binario da consulta DNS. Crie src/construtor.rs:
use crate::protocolo::TipoRegistro;
use rand::Rng;
/// Constroi um pacote DNS de consulta em formato binario.
pub fn construir_query(dominio: &str, tipo: TipoRegistro) -> Vec<u8> {
let mut pacote = Vec::new();
// Gerar ID aleatorio para a consulta
let id: u16 = rand::thread_rng().gen();
pacote.extend_from_slice(&id.to_be_bytes());
// Flags: consulta padrao com recursao desejada (RD=1)
let flags: u16 = 0x0100; // QR=0, OPCODE=0, RD=1
pacote.extend_from_slice(&flags.to_be_bytes());
// Contagem de perguntas: 1
pacote.extend_from_slice(&1u16.to_be_bytes());
// Contagem de respostas, autoridade, adicional: 0
pacote.extend_from_slice(&0u16.to_be_bytes());
pacote.extend_from_slice(&0u16.to_be_bytes());
pacote.extend_from_slice(&0u16.to_be_bytes());
// Codificar o nome do dominio
codificar_nome(&mut pacote, dominio);
// Tipo de registro
pacote.extend_from_slice(&(tipo as u16).to_be_bytes());
// Classe: IN (Internet) = 1
pacote.extend_from_slice(&1u16.to_be_bytes());
pacote
}
/// Codifica um nome de dominio no formato DNS.
/// "www.exemplo.com" -> [3, w, w, w, 7, e, x, e, m, p, l, o, 3, c, o, m, 0]
fn codificar_nome(pacote: &mut Vec<u8>, dominio: &str) {
for parte in dominio.split('.') {
let bytes = parte.as_bytes();
pacote.push(bytes.len() as u8);
pacote.extend_from_slice(bytes);
}
pacote.push(0); // Terminador
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn teste_codificacao_nome() {
let mut pacote = Vec::new();
codificar_nome(&mut pacote, "www.exemplo.com");
assert_eq!(pacote[0], 3); // tamanho de "www"
assert_eq!(pacote[1..4], *b"www");
assert_eq!(pacote[4], 7); // tamanho de "exemplo"
assert_eq!(*pacote.last().unwrap(), 0); // terminador
}
#[test]
fn teste_construir_query() {
let query = construir_query("example.com", TipoRegistro::A);
// Cabecalho = 12 bytes
assert!(query.len() > 12);
// Flags com recursao desejada
assert_eq!(query[2], 0x01);
assert_eq!(query[3], 0x00);
}
}
O formato DNS codifica nomes de dominio como sequencias de labels: cada label e precedida por um byte indicando seu comprimento, e o nome termina com um byte zero. A query completa inclui cabecalho de 12 bytes, o nome codificado, o tipo de registro e a classe.
Passo 3: Parser de Respostas
O parser interpreta a resposta binaria do servidor DNS. Crie src/parser.rs:
use crate::protocolo::{CabecalhoDns, DadosRegistro, RegistroResposta, TipoRegistro};
/// Parseia um pacote DNS de resposta.
pub fn parsear_resposta(
dados: &[u8],
) -> Result<(CabecalhoDns, Vec<RegistroResposta>), String> {
if dados.len() < 12 {
return Err("Resposta muito curta para conter cabecalho DNS".to_string());
}
let cabecalho = parsear_cabecalho(dados);
// Verificar se e uma resposta (bit QR = 1)
if cabecalho.flags & 0x8000 == 0 {
return Err("Pacote nao e uma resposta DNS".to_string());
}
// Verificar codigo de erro (RCODE nos 4 bits inferiores)
let rcode = cabecalho.flags & 0x000F;
if rcode != 0 {
let msg_erro = match rcode {
1 => "Erro de formato na consulta",
2 => "Falha no servidor",
3 => "Dominio nao encontrado (NXDOMAIN)",
4 => "Tipo de consulta nao suportado",
5 => "Consulta recusada",
_ => "Erro desconhecido",
};
return Err(format!("Servidor retornou erro: {} (RCODE={})", msg_erro, rcode));
}
let mut posicao = 12;
// Pular perguntas
for _ in 0..cabecalho.contagem_perguntas {
posicao = pular_nome(dados, posicao)?;
posicao += 4; // tipo (2) + classe (2)
}
// Parsear respostas
let mut registros = Vec::new();
let total_registros = cabecalho.contagem_respostas
+ cabecalho.contagem_autoridade
+ cabecalho.contagem_adicional;
for _ in 0..total_registros {
if posicao >= dados.len() {
break;
}
let (registro, nova_posicao) = parsear_registro(dados, posicao)?;
registros.push(registro);
posicao = nova_posicao;
}
Ok((cabecalho, registros))
}
fn parsear_cabecalho(dados: &[u8]) -> CabecalhoDns {
CabecalhoDns {
id: u16::from_be_bytes([dados[0], dados[1]]),
flags: u16::from_be_bytes([dados[2], dados[3]]),
contagem_perguntas: u16::from_be_bytes([dados[4], dados[5]]),
contagem_respostas: u16::from_be_bytes([dados[6], dados[7]]),
contagem_autoridade: u16::from_be_bytes([dados[8], dados[9]]),
contagem_adicional: u16::from_be_bytes([dados[10], dados[11]]),
}
}
/// Decodifica um nome de dominio do formato DNS, suportando compressao.
fn decodificar_nome(dados: &[u8], mut posicao: usize) -> Result<(String, usize), String> {
let mut partes = Vec::new();
let mut posicao_final = 0;
let mut seguiu_ponteiro = false;
loop {
if posicao >= dados.len() {
return Err("Posicao fora dos limites ao decodificar nome".to_string());
}
let tamanho = dados[posicao] as usize;
if tamanho == 0 {
if !seguiu_ponteiro {
posicao_final = posicao + 1;
}
break;
}
// Verificar se e um ponteiro de compressao (2 bits superiores = 11)
if tamanho & 0xC0 == 0xC0 {
if posicao + 1 >= dados.len() {
return Err("Ponteiro de compressao incompleto".to_string());
}
if !seguiu_ponteiro {
posicao_final = posicao + 2;
}
let offset = ((tamanho & 0x3F) << 8) | dados[posicao + 1] as usize;
posicao = offset;
seguiu_ponteiro = true;
continue;
}
posicao += 1;
if posicao + tamanho > dados.len() {
return Err("Label excede os limites do pacote".to_string());
}
let label = String::from_utf8_lossy(&dados[posicao..posicao + tamanho]).to_string();
partes.push(label);
posicao += tamanho;
}
let posicao_retorno = if seguiu_ponteiro {
posicao_final
} else {
posicao_final
};
Ok((partes.join("."), posicao_retorno))
}
/// Pula um nome codificado sem decodifica-lo.
fn pular_nome(dados: &[u8], mut posicao: usize) -> Result<usize, String> {
loop {
if posicao >= dados.len() {
return Err("Posicao fora dos limites ao pular nome".to_string());
}
let tamanho = dados[posicao] as usize;
if tamanho == 0 {
return Ok(posicao + 1);
}
if tamanho & 0xC0 == 0xC0 {
return Ok(posicao + 2); // Ponteiro de compressao = 2 bytes
}
posicao += 1 + tamanho;
}
}
/// Parseia um registro de recurso DNS.
fn parsear_registro(
dados: &[u8],
posicao: usize,
) -> Result<(RegistroResposta, usize), String> {
let (nome, mut pos) = decodificar_nome(dados, posicao)?;
if pos + 10 > dados.len() {
return Err("Registro incompleto".to_string());
}
let tipo_registro = u16::from_be_bytes([dados[pos], dados[pos + 1]]);
let classe = u16::from_be_bytes([dados[pos + 2], dados[pos + 3]]);
let ttl = u32::from_be_bytes([dados[pos + 4], dados[pos + 5], dados[pos + 6], dados[pos + 7]]);
let tamanho_dados = u16::from_be_bytes([dados[pos + 8], dados[pos + 9]]) as usize;
pos += 10;
if pos + tamanho_dados > dados.len() {
return Err("Dados do registro excedem o pacote".to_string());
}
let tipo = TipoRegistro::from_u16(tipo_registro);
let dados_registro = match tipo {
Some(TipoRegistro::A) if tamanho_dados == 4 => {
DadosRegistro::A([dados[pos], dados[pos + 1], dados[pos + 2], dados[pos + 3]])
}
Some(TipoRegistro::AAAA) if tamanho_dados == 16 => {
let mut ip = [0u8; 16];
ip.copy_from_slice(&dados[pos..pos + 16]);
DadosRegistro::AAAA(ip)
}
Some(TipoRegistro::CNAME) | Some(TipoRegistro::NS) => {
let (nome_decodificado, _) = decodificar_nome(dados, pos)?;
if tipo == Some(TipoRegistro::CNAME) {
DadosRegistro::CNAME(nome_decodificado)
} else {
DadosRegistro::NS(nome_decodificado)
}
}
Some(TipoRegistro::MX) if tamanho_dados >= 3 => {
let prioridade = u16::from_be_bytes([dados[pos], dados[pos + 1]]);
let (servidor, _) = decodificar_nome(dados, pos + 2)?;
DadosRegistro::MX {
prioridade,
servidor,
}
}
Some(TipoRegistro::TXT) if tamanho_dados >= 1 => {
let txt_len = dados[pos] as usize;
let texto = String::from_utf8_lossy(
&dados[pos + 1..pos + 1 + txt_len.min(tamanho_dados - 1)],
)
.to_string();
DadosRegistro::TXT(texto)
}
_ => DadosRegistro::Desconhecido(dados[pos..pos + tamanho_dados].to_vec()),
};
pos += tamanho_dados;
Ok((
RegistroResposta {
nome,
tipo_registro,
classe,
ttl,
dados: dados_registro,
},
pos,
))
}
O parser lida com uma das partes mais complexas do DNS: a compressao de nomes. Para economizar espaco, nomes repetidos no pacote sao substituidos por ponteiros de 2 bytes que apontam para a primeira ocorrencia do nome. O parser segue esses ponteiros recursivamente para reconstruir o nome completo.
Passo 4: Juntando Tudo no main.rs
Crie src/main.rs:
mod construtor;
mod parser;
mod protocolo;
use clap::Parser;
use colored::*;
use protocolo::TipoRegistro;
use std::net::UdpSocket;
use std::time::{Duration, Instant};
/// Resolver DNS - consulta servidores DNS e exibe resultados
#[derive(Parser)]
#[command(name = "dns-resolver")]
#[command(about = "Resolve nomes de dominio consultando servidores DNS")]
struct Argumentos {
/// Nome de dominio para resolver
dominio: String,
/// Tipo de registro (A, AAAA, CNAME, MX, NS, TXT)
#[arg(short, long, default_value = "A")]
tipo: String,
/// Servidor DNS para consultar
#[arg(short, long, default_value = "8.8.8.8")]
servidor: String,
/// Porta do servidor DNS
#[arg(short, long, default_value = "53")]
porta: u16,
/// Timeout em segundos
#[arg(long, default_value = "5")]
timeout: u64,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Argumentos::parse();
let tipo_registro = TipoRegistro::from_str(&args.tipo).unwrap_or_else(|| {
eprintln!(
"Tipo '{}' nao suportado. Usando A como padrao.",
args.tipo
);
TipoRegistro::A
});
println!("{}", "=== Resolver DNS ===".green().bold());
println!("Dominio: {}", args.dominio.cyan());
println!("Tipo: {}", tipo_registro.nome().yellow());
println!(
"Servidor: {}:{}\n",
args.servidor, args.porta
);
// Construir a query DNS
let query = construtor::construir_query(&args.dominio, tipo_registro);
println!(
"Tamanho da query: {} bytes",
query.len().to_string().dimmed()
);
// Criar socket UDP e enviar a consulta
let socket = UdpSocket::bind("0.0.0.0:0")?;
socket.set_read_timeout(Some(Duration::from_secs(args.timeout)))?;
let endereco_servidor = format!("{}:{}", args.servidor, args.porta);
let inicio = Instant::now();
socket.send_to(&query, &endereco_servidor)?;
// Receber resposta
let mut buffer = [0u8; 512];
let (tamanho, _origem) = match socket.recv_from(&mut buffer) {
Ok(resultado) => resultado,
Err(e) => {
eprintln!(
"{} Timeout ou erro ao receber resposta: {}",
"[ERRO]".red().bold(),
e
);
return Ok(());
}
};
let duracao = inicio.elapsed();
println!(
"Resposta recebida: {} bytes em {:.1}ms\n",
tamanho,
duracao.as_secs_f64() * 1000.0
);
// Parsear a resposta
let resposta = &buffer[..tamanho];
match parser::parsear_resposta(resposta) {
Ok((cabecalho, registros)) => {
println!(
"{} (ID: {:#06x}, respostas: {}, autoridade: {}, adicional: {})",
"Cabecalho:".white().bold(),
cabecalho.id,
cabecalho.contagem_respostas,
cabecalho.contagem_autoridade,
cabecalho.contagem_adicional
);
println!();
if registros.is_empty() {
println!("{}", "Nenhum registro encontrado.".yellow());
} else {
println!(
"{:<30} {:>6} {:>8} {}",
"NOME".white().bold(),
"TIPO".white().bold(),
"TTL".white().bold(),
"DADOS".white().bold()
);
println!("{}", "-".repeat(70).dimmed());
for registro in ®istros {
let tipo_nome = TipoRegistro::from_u16(registro.tipo_registro)
.map(|t| t.nome().to_string())
.unwrap_or_else(|| format!("TYPE{}", registro.tipo_registro));
let nome_exibir = if registro.nome.len() > 28 {
format!("{}...", ®istro.nome[..25])
} else {
registro.nome.clone()
};
println!(
"{:<30} {:>6} {:>7}s {}",
nome_exibir,
tipo_nome.cyan(),
registro.ttl,
registro.dados.to_string().green()
);
}
}
println!(
"\n{} Consulta concluida em {:.1}ms",
"---".dimmed(),
duracao.as_secs_f64() * 1000.0
);
}
Err(e) => {
eprintln!("{} Erro ao parsear resposta: {}", "[ERRO]".red().bold(), e);
}
}
Ok(())
}
O programa principal monta a query, envia para o servidor DNS via UDP, recebe a resposta e apresenta os registros de forma formatada. O timeout protege contra servidores que nao respondem.
Como Executar
# Compilar
cargo build --release
# Consultar registro A (endereco IPv4)
cargo run -- google.com
# Consultar registros MX (servidores de email)
cargo run -- gmail.com --tipo MX
# Consultar servidores de nomes (NS)
cargo run -- rust-lang.org --tipo NS
# Usar servidor DNS diferente (Cloudflare)
cargo run -- example.com --servidor 1.1.1.1
# Consultar registro AAAA (endereco IPv6)
cargo run -- google.com --tipo AAAA
Saida esperada:
=== Resolver DNS ===
Dominio: google.com
Tipo: A
Servidor: 8.8.8.8:53
Tamanho da query: 28 bytes
Resposta recebida: 44 bytes em 12.3ms
Cabecalho: (ID: 0xa1b2, respostas: 1, autoridade: 0, adicional: 0)
NOME TIPO TTL DADOS
----------------------------------------------------------------------
google.com A 299s 142.250.79.46
--- Consulta concluida em 12.3ms
Desafios para Expandir
- Cache local: Implemente um cache de respostas DNS usando
HashMapcom TTL, evitando consultas repetidas para o mesmo dominio dentro do periodo de validade. - Consulta recursiva: Adicione a capacidade de resolver nomes seguindo a cadeia de servidores DNS raiz -> TLD -> autoritativo, em vez de depender da recursao do servidor.
- DNS sobre HTTPS (DoH): Implemente suporte a DNS over HTTPS usando a crate
reqwest, enviando as queries via HTTP POST para servidores comohttps://dns.google/dns-query. - Modo servidor: Transforme o resolver em um servidor DNS local que escuta na porta 53, responde consultas usando cache local e encaminha o restante para um servidor upstream.
- Benchmark de servidores: Adicione um modo
--benchmarkque consulta o mesmo dominio em multiplos servidores DNS e compara os tempos de resposta.