Hotdry Blog

Article

纯 Rust 从零实现 DNS 解析器:RFC 1035 协议解析与 UDP Socket 实践

不依赖任何外部 DNS 库,手写 RFC 1035 协议解析、域名压缩与 UDP 递归查询,提供可直接落地的工程参数与核心代码模式。

2026-04-02systems

在现代网络编程中,DNS 解析器通常是作为基础设施工具而被直接调用的,极少有人从底层协议重新实现。然而,对于系统工程师而言,理解 DNS 协议的每一个字节、掌握 UDP Socket 的编程细节,不仅能够提升网络调试能力,还能在特定场景下构建轻量级、自定义的解析器。本文将展示如何从零用纯 Rust 实现一个符合 RFC 1035 标准的 DNS 解析器,不依赖任何外部 DNS 库,直接操作字节与 UDP Socket。

RFC 1035 消息格式与核心数据结构

RFC 1035 定义了 DNS 消息的标准二进制格式。一个完整的 DNS 消息由五个部分组成:Header(头部)、Question(问题段)、Answer(回答段)、Authority(权威段)和 Additional(附加段)。其中 Header 是固定长度的 12 字节,携带了消息的标识、标志位和各个段落的计数信息。实现解析器的第一步是定义这些核心数据结构。

#[derive(Debug, Clone)]
pub struct DnsHeader {
    pub id: u16,           // 事务 ID
    pub qr: bool,          // 0=查询, 1=响应
    pub opcode: u8,        // 操作码
    pub aa: bool,          // 权威应答标志
    pub tc: bool,          // 截断标志
    pub rd: bool,          // 期望递归标志
    pub ra: bool,          // 递归可用标志
    pub rcode: u8,         // 响应码
    pub qdcount: u16,      // 问题数
    pub ancount: u16,      // 回答数
    pub nscount: u16,      // 权威数
    pub arcount: u16,      // 附加数
}

在编码时,Header 的各个字段需要按照大端字节序(Big-Endian)写入,这与网络协议的标准字节序一致。Rust 的 byteorder crate 或者手动的位运算都可以完成这一工作。值得注意的是,Flags 字段(QR、Opcode、AA、TC、RD、RA、RCODE)需要打包成两个字节进行传输,这一点在实现时容易出错。

Question 段的核心是 QNAME,即查询的域名。RFC 1035 使用一种独特的压缩格式:每个标签(Label)由一个长度字节开头,后跟该标签的 ASCII 字符,结尾以一个零字节表示域名结束。例如,example.com 会被编码为 7example3com0,其中 7 表示后续 7 个字节是 example3 表示后续 3 个字节是 com0 表示结束。

域名解析与指针压缩的实现细节

除了上述基本编码方式,RFC 1035 还定义了指针压缩机制,用于在同一个消息中引用重复出现的域名。指针由两个最高位均为 1 的字节(0xC0)开头,后跟一个偏移量,指向消息中该名称首次出现的位置。在实现解析器时,需要同时支持这两种格式。

fn parse_name(buf: &[u8], pos: &mut usize) -> Result<String, DnsError> {
    let mut labels = Vec::new();
    let mut jump_count = 0;
    let initial_pos = *pos;
    
    loop {
        if *pos >= buf.len() {
            return Err(DnsError::BufferUnderflow);
        }
        
        let length = buf[*pos];
        
        // 指针压缩:最高两位为 11
        if (length & 0xC0) == 0xC0 {
            if *pos + 1 >= buf.len() {
                return Err(DnsError::BufferUnderflow);
            }
            let offset = ((length & 0x3F) as usize) << 8 | buf[*pos + 1] as usize;
            *pos = offset;
            jump_count += 1;
            if jump_count > 128 {
                return Err(DnsError::NameLoopDetected);
            }
            continue;
        }
        
        // 正常标签
        if length == 0 {
            *pos += 1;
            break;
        }
        
        *pos += 1;
        if *pos + length as usize > buf.len() {
            return Err(DnsError::BufferUnderflow);
        }
        
        let label = std::str::from_utf8(&buf[*pos..*pos + length as usize])
            .map_err(|_| DnsError::InvalidUtf8)?;
        labels.push(label.to_string());
        *pos += length as usize;
    }
    
    if labels.is_empty() {
        Ok(".".to_string())
    } else {
        Ok(labels.join("."))
    }
}

上述代码展示了一个健壮的域名解析函数,它能够处理正常的标签序列、指针压缩以及潜在的循环引用。jump_count 的上限设为 128,足以防止恶意构造的循环指针导致的无限循环。

UDP Socket 与递归查询的工程实现

DNS 解析器通常使用 UDP 协议在 53 端口上进行通信。相比 TCP,UDP 更加轻量,适合大多数单次查询场景。实现时需要关注超时控制、重试机制和事务 ID 匹配。

use std::net::UdpSocket;
use std::time::Duration;
use std::io;

pub struct DnsResolver {
    socket: UdpSocket,
    server: String,
    timeout: Duration,
    retries: u8,
}

impl DnsResolver {
    pub fn new(server: &str) -> io::Result<Self> {
        let socket = UdpSocket::bind("0.0.0.0:0")?;
        socket.set_read_timeout(Some(Duration::from_secs(5)))?;
        socket.set_write_timeout(Some(Duration::from_secs(5)))?;
        
        Ok(Self {
            socket,
            server: server.to_string(),
            timeout: Duration::from_secs(5),
            retries: 3,
        })
    }
    
    pub fn resolve(&self, domain: &str, qtype: RecordType) -> io::Result<Vec<DnsResponse>> {
        let transaction_id = rand::random::<u16>();
        let query = self.build_query(domain, qtype, transaction_id)?;
        
        for attempt in 0..self.retries {
            self.socket.send_to(&query, format!("{}:53", self.server))?;
            
            let mut response_buf = [0u8; 512];
            match self.socket.recv_from(&mut response_buf) {
                Ok((len, _)) => {
                    let response = self.parse_response(&response_buf[..len], transaction_id)?;
                    return Ok(response);
                }
                Err(e) if e.kind() == io::ErrorKind::TimedOut => {
                    if attempt == self.retries - 1 {
                        return Err(e);
                    }
                    continue;
                }
                Err(e) => return Err(e),
            }
        }
        
        Err(io::Error::new(io::ErrorKind::TimedOut, "DNS query timed out"))
    }
}

这段代码展示了 DNS 解析器的核心查询逻辑。关键工程参数包括:UDP 绑定地址使用 0.0.0.0:0 意味着由系统分配临时端口;超时时间设为 5 秒适合大多数网络环境;重试次数设为 3 次可以在网络不稳定时提供一定的容错能力,同时避免过度重试导致的延迟累积。

响应解析与资源记录处理

解析 DNS 响应时,需要按照各段的计数依次读取 Question、Answer、Authority 和 Additional 记录。每条资源记录(Resource Record)包含 NAME、TYPE、CLASS、TTL、RDLENGTH 和 RDATA 字段。其中 RDATA 的格式取决于记录类型:A 记录是 4 字节的 IPv4 地址,AAAA 记录是 16 字节的 IPv6 地址,CNAME 和 NS 记录则是域名格式。

#[derive(Debug, Clone)]
pub enum RecordType {
    A,      // 1
    NS,     // 2
    CNAME,  // 5
    SOA,    // 6
    PTR,    // 12
    MX,     // 15
    TXT,    // 16
    AAAA,   // 28
    SRV,    // 33
    Unknown(u16),
}

impl RecordType {
    pub fn from_u16(value: u16) -> Self {
        match value {
            1 => RecordType::A,
            2 => RecordType::NS,
            5 => RecordType::CNAME,
            6 => RecordType::SOA,
            12 => RecordType::PTR,
            15 => RecordType::MX,
            16 => RecordType::TXT,
            28 => RecordType::AAAA,
            33 => RecordType::SRV,
            _ => RecordType::Unknown(value),
        }
    }
}

实现递归查询时,解析器需要检查响应中的 AUTHORITY 段,提取权威 DNS 服务器的地址,然后向这些服务器发起迭代查询。这一过程可以通过维护一个待查询服务器列表来实现,直到获得最终答案或达到最大查询深度。

实用参数清单与监控建议

在生产环境中部署自研 DNS 解析器时,以下参数和监控点值得关注:查询超时建议设置为 2 至 5 秒,具体取决于网络环境;重试次数推荐 2 至 3 次;最大递归深度通常设为 10 层,超过则放弃查询并返回错误;缓存策略上,A 记录缓存时间建议 60 至 300 秒,AAAA 记录因 IPv6 地址相对稳定可设置更长的 TTL。

监控方面,需要记录每秒查询量、查询延迟分布(特别是 P50、P95、P99)、超时和失败率以及各记录类型的分布。日志应包含事务 ID、查询域名、查询类型、耗时和最终状态,便于问题追溯。

小结

从零实现一个符合 RFC 1035 标准的 DNS 解析器,核心挑战在于准确地解析和编码变长的域名、处理指针压缩、正确操作 UDP Socket 以及实现可靠的超时与重试机制。通过手动实现这些底层细节,不仅能深入理解 DNS 协议的工作原理,还能在特定场景下构建轻量、高效且完全可控的解析器。

资料来源:RFC 1035 规范文档;GitHub 上 razvandimescu/numa 项目的架构设计参考。

systems