Resolver DNS em Rust

Construa um resolver DNS do zero em Rust, implementando o protocolo DNS manualmente com queries UDP e parsing de respostas.

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 &registros {
                    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!("{}...", &registro.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

  1. Cache local: Implemente um cache de respostas DNS usando HashMap com TTL, evitando consultas repetidas para o mesmo dominio dentro do periodo de validade.
  2. 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.
  3. DNS sobre HTTPS (DoH): Implemente suporte a DNS over HTTPS usando a crate reqwest, enviando as queries via HTTP POST para servidores como https://dns.google/dns-query.
  4. 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.
  5. Benchmark de servidores: Adicione um modo --benchmark que consulta o mesmo dominio em multiplos servidores DNS e compara os tempos de resposta.

Veja Tambem