DNS Resolver em Zig — Tutorial Passo a Passo

DNS Resolver em Zig — Tutorial Passo a Passo

Neste tutorial, vamos construir um resolver DNS customizado em Zig que envia consultas DNS via UDP, parseia as respostas e exibe os registros encontrados. Este é um projeto fascinante para entender como a internet resolve nomes de domínio.

O Que Vamos Construir

Nosso DNS resolver vai:

  • Enviar consultas DNS via UDP para servidores DNS públicos
  • Parsear respostas DNS incluindo headers, questões e registros
  • Suportar tipos de registro A, AAAA, CNAME e MX
  • Implementar compressão de nomes DNS (label compression)
  • Exibir resultados formatados com TTL e classe

Por Que Este Projeto?

DNS é a espinha dorsal da internet. Entender como funciona — desde a codificação binária das mensagens até a compressão de nomes — é fundamental para qualquer programador de sistemas. Em Zig, podemos manipular bytes diretamente com total controle, ideal para protocolos binários.

Pré-requisitos

Passo 1: Estrutura do Projeto

mkdir dns-resolver
cd dns-resolver
zig init

Passo 2: Estruturas do Protocolo DNS

O protocolo DNS define um formato binário específico para mensagens. Cada mensagem tem um header de 12 bytes, seguido de seções de pergunta, resposta, autoridade e adicional.

const std = @import("std");
const net = std.net;
const posix = std.posix;
const io = std.io;
const mem = std.mem;
const fmt = std.fmt;

/// Tipos de registro DNS.
const TipoRegistro = enum(u16) {
    A = 1, // IPv4
    NS = 2, // Name Server
    CNAME = 5, // Alias
    MX = 15, // Mail Exchange
    AAAA = 28, // IPv6
    _,

    pub fn nome(self: TipoRegistro) []const u8 {
        return switch (self) {
            .A => "A",
            .NS => "NS",
            .CNAME => "CNAME",
            .MX => "MX",
            .AAAA => "AAAA",
            _ => "UNKNOWN",
        };
    }
};

/// Header DNS (12 bytes).
const DnsHeader = struct {
    id: u16,
    flags: u16,
    num_questoes: u16,
    num_respostas: u16,
    num_autoridade: u16,
    num_adicional: u16,
};

/// Um registro DNS parseado.
const DnsRecord = struct {
    nome: [256]u8,
    nome_len: usize,
    tipo: TipoRegistro,
    classe: u16,
    ttl: u32,
    dados: [512]u8,
    dados_len: usize,

    pub fn nomeStr(self: *const DnsRecord) []const u8 {
        return self.nome[0..self.nome_len];
    }

    pub fn dadosStr(self: *const DnsRecord) []const u8 {
        return self.dados[0..self.dados_len];
    }
};

Passo 3: Construção da Query DNS

/// Constrói uma query DNS em formato binário.
fn construirQuery(
    buf: []u8,
    dominio: []const u8,
    tipo: TipoRegistro,
    id: u16,
) !usize {
    var pos: usize = 0;

    // Header
    // ID
    buf[pos] = @intCast(id >> 8);
    buf[pos + 1] = @intCast(id & 0xFF);
    pos += 2;
    // Flags: recursion desired
    buf[pos] = 0x01;
    buf[pos + 1] = 0x00;
    pos += 2;
    // Questions: 1
    buf[pos] = 0x00;
    buf[pos + 1] = 0x01;
    pos += 2;
    // Answer, Authority, Additional: 0
    @memset(buf[pos .. pos + 6], 0);
    pos += 6;

    // Question section: encode domain name
    // "www.example.com" -> [3]www[7]example[3]com[0]
    var it = mem.splitScalar(u8, dominio, '.');
    while (it.next()) |label| {
        if (label.len == 0 or label.len > 63) return error.DominioInvalido;
        buf[pos] = @intCast(label.len);
        pos += 1;
        @memcpy(buf[pos .. pos + label.len], label);
        pos += label.len;
    }
    buf[pos] = 0; // terminator
    pos += 1;

    // Type
    const tipo_val = @intFromEnum(tipo);
    buf[pos] = @intCast(tipo_val >> 8);
    buf[pos + 1] = @intCast(tipo_val & 0xFF);
    pos += 2;

    // Class: IN (Internet) = 1
    buf[pos] = 0x00;
    buf[pos + 1] = 0x01;
    pos += 2;

    return pos;
}

Passo 4: Parser de Respostas DNS

/// Lê um u16 big-endian de um buffer.
fn lerU16(buf: []const u8, pos: usize) u16 {
    return (@as(u16, buf[pos]) << 8) | buf[pos + 1];
}

/// Lê um u32 big-endian de um buffer.
fn lerU32(buf: []const u8, pos: usize) u32 {
    return (@as(u32, buf[pos]) << 24) |
        (@as(u32, buf[pos + 1]) << 16) |
        (@as(u32, buf[pos + 2]) << 8) |
        buf[pos + 3];
}

/// Decodifica um nome DNS com suporte a compressão de ponteiros.
/// DNS usa compressão: se os dois bits mais significativos de um byte são 11,
/// os próximos 14 bits são um offset para o início do nome comprimido.
fn decodificarNome(
    buf: []const u8,
    pos_inicial: usize,
    nome_buf: *[256]u8,
) !struct { len: usize, bytes_consumidos: usize } {
    var pos = pos_inicial;
    var nome_pos: usize = 0;
    var bytes_consumidos: usize = 0;
    var seguiu_ponteiro = false;

    while (pos < buf.len) {
        const len_byte = buf[pos];

        if (len_byte == 0) {
            // Fim do nome
            if (!seguiu_ponteiro) bytes_consumidos = pos - pos_inicial + 1;
            break;
        }

        if (len_byte & 0xC0 == 0xC0) {
            // Ponteiro de compressão
            if (!seguiu_ponteiro) {
                bytes_consumidos = pos - pos_inicial + 2;
                seguiu_ponteiro = true;
            }
            const offset = (@as(u16, len_byte & 0x3F) << 8) | buf[pos + 1];
            pos = offset;
            continue;
        }

        // Label normal
        if (nome_pos > 0) {
            nome_buf[nome_pos] = '.';
            nome_pos += 1;
        }
        const label_len = len_byte;
        @memcpy(nome_buf[nome_pos .. nome_pos + label_len], buf[pos + 1 .. pos + 1 + label_len]);
        nome_pos += label_len;
        pos += 1 + label_len;
    }

    if (!seguiu_ponteiro) bytes_consumidos = pos - pos_inicial + 1;

    return .{ .len = nome_pos, .bytes_consumidos = bytes_consumidos };
}

/// Parseia a seção de respostas DNS.
fn parsearRespostas(
    buf: []const u8,
    pos_inicial: usize,
    num_registros: u16,
    registros: *std.ArrayList(DnsRecord),
) !usize {
    var pos = pos_inicial;

    for (0..num_registros) |_| {
        var registro: DnsRecord = undefined;

        // Decodificar nome
        const nome_result = try decodificarNome(buf, pos, &registro.nome);
        registro.nome_len = nome_result.len;
        pos += nome_result.bytes_consumidos;

        // Tipo, classe, TTL, tamanho dos dados
        registro.tipo = @enumFromInt(lerU16(buf, pos));
        pos += 2;
        registro.classe = lerU16(buf, pos);
        pos += 2;
        registro.ttl = lerU32(buf, pos);
        pos += 4;
        const rdlength = lerU16(buf, pos);
        pos += 2;

        // Interpretar dados conforme o tipo
        registro.dados_len = 0;

        switch (registro.tipo) {
            .A => {
                if (rdlength == 4) {
                    const ip = fmt.bufPrint(&registro.dados, "{d}.{d}.{d}.{d}", .{
                        buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3],
                    }) catch "";
                    registro.dados_len = ip.len;
                }
            },
            .AAAA => {
                if (rdlength == 16) {
                    var ip_buf: [39]u8 = undefined;
                    var ip_len: usize = 0;
                    var j: usize = 0;
                    while (j < 16) : (j += 2) {
                        if (j > 0) {
                            ip_buf[ip_len] = ':';
                            ip_len += 1;
                        }
                        const parte = fmt.bufPrint(ip_buf[ip_len..], "{x:0>2}{x:0>2}", .{
                            buf[pos + j], buf[pos + j + 1],
                        }) catch break;
                        ip_len += parte.len;
                    }
                    @memcpy(registro.dados[0..ip_len], ip_buf[0..ip_len]);
                    registro.dados_len = ip_len;
                }
            },
            .CNAME, .NS => {
                var nome_dados: [256]u8 = undefined;
                const result = try decodificarNome(buf, pos, &nome_dados);
                @memcpy(registro.dados[0..result.len], nome_dados[0..result.len]);
                registro.dados_len = result.len;
            },
            .MX => {
                const preferencia = lerU16(buf, pos);
                var nome_mx: [256]u8 = undefined;
                const result = try decodificarNome(buf, pos + 2, &nome_mx);
                const mx_str = fmt.bufPrint(&registro.dados, "{d} {s}", .{
                    preferencia, nome_mx[0..result.len],
                }) catch "";
                registro.dados_len = mx_str.len;
            },
            _ => {
                const len = @min(rdlength, @as(u16, @intCast(registro.dados.len)));
                @memcpy(registro.dados[0..len], buf[pos .. pos + len]);
                registro.dados_len = len;
            },
        }

        pos += rdlength;
        try registros.append(registro);
    }

    return pos;
}

Passo 5: Função Main e Execução

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const stdout = io.getStdOut().writer();
    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    var dominio: []const u8 = "ziglang.org";
    var tipo = TipoRegistro.A;
    var servidor_dns: [4]u8 = .{ 8, 8, 8, 8 }; // Google DNS

    if (args.len >= 2) {
        dominio = args[1];
    }
    if (args.len >= 3) {
        const tipo_str = args[2];
        if (mem.eql(u8, tipo_str, "A")) tipo = .A
        else if (mem.eql(u8, tipo_str, "AAAA")) tipo = .AAAA
        else if (mem.eql(u8, tipo_str, "CNAME")) tipo = .CNAME
        else if (mem.eql(u8, tipo_str, "MX")) tipo = .MX
        else if (mem.eql(u8, tipo_str, "NS")) tipo = .NS;
    }

    try stdout.print(
        \\
        \\  ==========================================
        \\     DNS RESOLVER - Zig
        \\  ==========================================
        \\  Consultando: {s}
        \\  Tipo: {s}
        \\  Servidor DNS: {d}.{d}.{d}.{d}
        \\  ==========================================
        \\
    , .{ dominio, tipo.nome(), servidor_dns[0], servidor_dns[1], servidor_dns[2], servidor_dns[3] });

    // Construir query
    var query_buf: [512]u8 = undefined;
    const query_len = try construirQuery(&query_buf, dominio, tipo, 0xABCD);

    // Enviar via UDP
    const socket = try posix.socket(posix.AF.INET, posix.SOCK.DGRAM, 0);
    defer posix.close(socket);

    const dest = net.Address.initIp4(servidor_dns, 53);
    _ = try posix.sendto(socket, query_buf[0..query_len], 0, &dest.any, dest.getOsSockLen());

    // Receber resposta
    var resp_buf: [4096]u8 = undefined;
    const resp_len = try posix.read(socket, &resp_buf);
    const resposta = resp_buf[0..resp_len];

    // Parsear header
    const header = DnsHeader{
        .id = lerU16(resposta, 0),
        .flags = lerU16(resposta, 2),
        .num_questoes = lerU16(resposta, 4),
        .num_respostas = lerU16(resposta, 6),
        .num_autoridade = lerU16(resposta, 8),
        .num_adicional = lerU16(resposta, 10),
    };

    const rcode = header.flags & 0x0F;
    try stdout.print("  Status: {s}\n", .{switch (rcode) {
        0 => "NOERROR",
        1 => "FORMERR",
        2 => "SERVFAIL",
        3 => "NXDOMAIN",
        else => "UNKNOWN",
    }});
    try stdout.print("  Respostas: {d}, Autoridade: {d}, Adicional: {d}\n\n", .{
        header.num_respostas, header.num_autoridade, header.num_adicional,
    });

    // Pular seção de questões
    var pos: usize = 12;
    for (0..header.num_questoes) |_| {
        var dummy: [256]u8 = undefined;
        const result = try decodificarNome(resposta, pos, &dummy);
        pos += result.bytes_consumidos;
        pos += 4; // type + class
    }

    // Parsear respostas
    var registros = std.ArrayList(DnsRecord).init(allocator);
    defer registros.deinit();

    pos = try parsearRespostas(resposta, pos, header.num_respostas, &registros);

    // Exibir resultados
    if (registros.items.len == 0) {
        try stdout.print("  Nenhum registro encontrado.\n", .{});
    } else {
        try stdout.print("  {s:<30} {s:<8} {s:<8} {s}\n", .{ "NOME", "TIPO", "TTL", "DADOS" });
        try stdout.print("  {s:-<70}\n", .{""});
        for (registros.items) |r| {
            try stdout.print("  {s:<30} {s:<8} {d:<8} {s}\n", .{
                r.nomeStr(), r.tipo.nome(), r.ttl, r.dadosStr(),
            });
        }
    }
}

Testes

test "construir query DNS" {
    var buf: [512]u8 = undefined;
    const len = try construirQuery(&buf, "example.com", .A, 0x1234);
    try std.testing.expect(len > 12); // pelo menos o header
    try std.testing.expectEqual(@as(u8, 0x12), buf[0]); // ID high byte
    try std.testing.expectEqual(@as(u8, 0x34), buf[1]); // ID low byte
}

test "decodificar nome simples" {
    // [3]www[7]example[3]com[0]
    const dados = [_]u8{ 3, 'w', 'w', 'w', 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0 };
    var nome: [256]u8 = undefined;
    const result = try decodificarNome(&dados, 0, &nome);
    try std.testing.expectEqualStrings("www.example.com", nome[0..result.len]);
}

test "lerU16 big-endian" {
    const dados = [_]u8{ 0x01, 0x00 };
    try std.testing.expectEqual(@as(u16, 256), lerU16(&dados, 0));
}

test "lerU32 big-endian" {
    const dados = [_]u8{ 0x00, 0x00, 0x01, 0x00 };
    try std.testing.expectEqual(@as(u32, 256), lerU32(&dados, 0));
}

Compilando e Executando

# Resolver um domínio (padrão: ziglang.org tipo A)
zig build run

# Resolver domínio específico
zig build run -- example.com

# Consultar tipo específico
zig build run -- google.com MX
zig build run -- ziglang.org AAAA

# Rodar testes
zig build test

Conceitos Aprendidos

  • Protocolo DNS com formato binário de mensagens
  • Sockets UDP para comunicação sem conexão
  • Big-endian e manipulação de bytes
  • Compressão de nomes com ponteiros de offset
  • Enums com valores para tipos de registro

Próximos Passos

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.