跳转到内容

DNS 解析器

源码文件:25 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/src/dns/mod.rs
  • Etemenanki/protocols/src/dns/message.rs
  • Etemenanki/protocols/src/error.rs
  • Etemenanki/protocols/src/helpers/parse.rs
  • Etemenanki/protocols/src/helpers/address_family.rs
  • Etemenanki/protocols/src/transports/tls/config.rs
  • Etemenanki/protocols/src/transports/connect.rs
  • Etemenanki/protocols/src/hysteria/connector.rs
  • Etemenanki/protocols/src/wireguard/connector.rs
  • Etemenanki/protocols/src/wireguard/device.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/instance.rs
  • Etemenanki/app/src/main.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/app/src/outbound/freedom.rs
  • Etemenanki/app/src/balancer.rs
  • Etemenanki/protocols/tests/unit/dns/mod.rs
  • Etemenanki/protocols/tests/unit/dns/message.rs
  • Etemenanki/protocols/tests/pipeline/dns_secure.rs
  • Etemenanki/app/tests/integration/e2e_dns.rs
  • Etemenanki/app/tests/support/mod.rs
  • Etemenanki/protocols/tests/support/mod.rs
  • katana/src/config.rs
  • katana/src/runtime.rs
  • katana/src/outbound/mod.rs

etemenanki_protocols::dns 把域名解析为出站要拨号的地址列表。它在四种后端之一的前面放了一个有上限、感知 TTL 的缓存。四种后端分别是:主机解析器(getaddrinfo)、基于 UDP 的普通 DNS、DNS over TLS(RFC 7858)和 DNS over HTTPS(RFC 8484)。对三种协议后端,它自带一个小型报文 codec,负责编码一个 A 或 AAAA 问题,并从响应中读出地址记录。

本页面向修改解析器、其 codec,或者修改 app 和 katana 构建、共享解析器方式的贡献者。同一功能面向运维者的视角,即 [dns] 表,见用户指南中的 DNS。

这个模块做四件事:

  • 根据配置字符串构建解析器。 Resolver::from_spec 是 "udp"、"tls" 和 "https" 获得含义的唯一位置,也是拒绝缺失字段的唯一位置。app 和 katana 都调用它,因此两个程序对 [dns] 表含义的理解不会出现分歧。
  • 把名称解析为地址。 Resolver::resolve 按后端给出的顺序返回全部地址,并去重。地址字面量原样返回,重复查询由缓存应答。
  • 在上限内缓存应答。 moka 把缓存限制在 CACHE_CAPACITY 个名称以内。它在内部维护时才执行这个上限,所以条目数可能短暂超出。每个条目在应答的 TTL 到期后失效,TTL 会被限制在 [MIN_TTL, MAX_TTL] 之内;system 后端的应答没有 TTL,保留 SYSTEM_TTL。
  • 只讲够用的 DNS。 message.rs 编码一个递归查询,并从不可信的字节中解码 A 和 AAAA 记录,过程中不会 panic、不会死循环,也不会越界读取。

地址族策略不属于这个模块。解析器返回它得到的全部结果,随后由 protocols/src/helpers/address_family.rs → resolve_candidates 应用出站的 AddressFamilyStrategy 和调用方的 socket 能力。解析器也不做任何路由决策,从不为路由器解析名称。它只服务于出站和负载均衡器的健康探测。

protocols/src/dns/mod.rs
pub const SYSTEM_TTL: Duration = Duration::from_secs(60);
pub const MIN_TTL: Duration = Duration::from_secs(5);
pub const MAX_TTL: Duration = Duration::from_secs(3600);
pub const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
const CACHE_CAPACITY: u64 = 8192;
const UDP_BUF: usize = 512;

下文的“限制”一节把它们与 codec 常量列在一起。

#[derive(Debug, Default, Clone, Copy)]
pub struct ResolverSpec<'a> {
pub backend: Option<&'a str>,
pub server: Option<&'a str>,
pub server_name: Option<&'a str>,
pub url: Option<&'a str>,
pub ca_pem: Option<&'a [u8]>,
}
impl Resolver {
pub fn from_spec(spec: ResolverSpec<'_>) -> io::Result<Self>;
}

ResolverSpec 保存配置文件中的字符串。它是一个结构体而不是参数列表,这样新增后端字段时不会破坏每个使用方的调用点。ca_pem 是 PEM 字节而不是路径:文件由调用方读取。app 的 app/src/instance.rs → build 和 katana 的 src/runtime.rs → build_outbounds 都会先对 ca_file 调用 std::fs::read,再调用 from_spec。无论使用哪种后端它们都会读取该文件,所以 ca_file 不存在时,构建会以原始的操作系统错误失败,例如 No such file or directory (os error 2)。

from_spec 对 backend 做精确匹配。匹配区分大小写,None 表示 "system"。

backend 必需字段 构建结果 忽略的字段
None 或 "system" 无 Resolver::system() server、server_name、url、ca_pem
"udp" server Backend::Udp(addr) server_name、url、ca_pem
"tls" server、server_name Backend::Tls { .. } url
"https" server、url Backend::https(server, url, ca_pem) server_name
其他任何值 错误

server 按 SocketAddr 解析("192.0.2.53:53"、"[2001:db8::53]:853"),所以必须显式写出端口,而且永远不能是主机名。否则解析器就得先解析自己的服务器,而它没有可用来解析的手段。所有拒绝都是 io::ErrorKind::InvalidInput,且都不会退回主机解析器:运维者指定了某个解析器,就不能在不告知的情况下换成另一个。错误文本列在下文的“失败路径”一节。

#[derive(Debug, Clone)]
pub enum Backend {
System,
Udp(SocketAddr),
Tls {
server: SocketAddr,
server_name: CompactString,
ca_pem: Option<Vec<u8>>,
},
Https {
server: SocketAddr,
host: CompactString,
path: CompactString,
ca_pem: Option<Vec<u8>>,
},
}
impl Backend {
pub fn https(server: SocketAddr, url: &str, ca_pem: Option<Vec<u8>>) -> io::Result<Self>;
}

Backend::https 自己拆分 URL,而不是引入 URL 解析库:

  1. URL 必须以 https:// 开头,否则以 dns: "<url>" is not an https:// url 拒绝。
  2. authority 延伸到第一个 / 为止,其余部分是请求路径。没有 / 时,路径为 /dns-query。
  3. authority 为空或包含 @ 时,以 dns: "<url>" has no usable host 拒绝。否则 userinfo 会混进被校验的名称里。
  4. authority 中第一个 : 之后的内容全部丢弃。实际拨号的地址是 server,所以 URL 中的端口在这里没有意义,也不能混进证书名称。

host 有两个用途:作为要校验的 TLS 服务器名称,以及作为 HTTP Host 头。由于只是简单地在 : 处拆分,带方括号的 IPv6 字面量不能用作 URL authority:https://[2001:db8::53]/dns-query 可以构建,但 host 会变成 [2001,没有任何证书能匹配。请在 URL 中使用 DNS 名称,把地址写在 server 里。

#[derive(Clone)]
pub struct Resolver {
inner: Arc<Inner>,
}
struct Inner {
backend: Backend,
min_ttl: Duration,
max_ttl: Duration,
tls: Option<ClientConfig>,
cache: moka::future::Cache<CompactString, Cached>,
}
#[derive(Clone)]
struct Cached {
addresses: Arc<Vec<IpAddr>>,
expires_at: Instant,
}
impl Resolver {
pub fn system() -> Self;
pub fn new(backend: Backend) -> io::Result<Self>;
pub fn from_spec(spec: ResolverSpec<'_>) -> io::Result<Self>;
pub fn with_ttl_bounds(self, min: Duration, max: Duration) -> Self;
pub async fn resolve(&self, host: &str) -> io::Result<Vec<IpAddr>>;
}

一个 Resolver 就是一个 Arc。克隆它会共享后端、TLS 客户端配置和缓存,所有使用方都依赖这一点。Default 即 Resolver::system()。Debug 实现只打印后端。

  • Resolver::new 仅对 Tls 和 Https 构建一次 ClientConfig,并存入 Inner.tls。ClientConfig::with_verify_mode 失败时它随之失败,详见下文 TLS 一节。
  • Resolver::build(私有)以 max_capacity(CACHE_CAPACITY) 和 time_to_live(MAX_TTL) 创建缓存,并把 min_ttl/max_ttl 设为 MIN_TTL/MAX_TTL。
  • with_ttl_bounds 用相同的后端和 TLS 配置以及同一个 moka 句柄的克隆,构建一个新的 Inner。已缓存的内容全部保留,只有限制区间改变:moka 的 time_to_live 兜底值仍是构建缓存时的 MAX_TTL。在固定的版本中,app 和 katana 都没有调用它,测试用它来强制过期。

查询相关的实现都是私有的:

fn build(backend: Backend, tls: Option<ClientConfig>) -> Self;
async fn lookup(&self, host: &str) -> io::Result<(Vec<IpAddr>, Option<Duration>)>;
async fn query_both(&self, host: &str, server: SocketAddr) -> io::Result<(Vec<IpAddr>, Option<Duration>)>;
async fn query(&self, host: &str, server: SocketAddr, qtype: u16) -> io::Result<Answer>;
async fn exchange_https(&self, server: SocketAddr, host: &str, path: &str, query: &[u8]) -> io::Result<Vec<u8>>;
async fn exchange_tls(&self, server: SocketAddr, query: &[u8]) -> io::Result<Vec<u8>>;
// free functions in the same module
async fn exchange_https_over(
mut stream: impl tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
host: &str,
path: &str,
query: &[u8],
) -> io::Result<Vec<u8>>;
async fn exchange_udp(server: SocketAddr, query: &[u8]) -> io::Result<Vec<u8>>;

只有解析重叠名称的调用方共享同一个缓存,缓存才有意义。因此两个程序都为每套出站只构建一个解析器,再把它的克隆交给所有需要解析的地方。

flowchart LR
  cfg["[dns] 表"] --> spec["ResolverSpec"]
  spec --> fs["Resolver::from_spec"]
  fs --> r["Resolver(一个 Arc)"]
  r --> free["FreedomConnector"]
  r --> tc["TransportConnector"]
  r --> hy["Hy2Connector::with_resolver"]
  r --> wg["WgConnector::with_resolver"]
  r --> bal["Balancer::spawn_probe"]
程序 解析器在哪里构建 生命周期 谁拿到克隆
etemenanki-app app/src/instance.rs → build,在任何出站之前 每个 generation 一个,存放在 Built.resolver 通过 build_outbound(ob, &resolver) 交给每个需要拨号的出站(blackhole 不需要):freedom(TCP 拨号和 UDP ResolvingUdp)、每个基于流的代理出站的 TransportConnector、Hysteria 2(解析服务器名称)和 WireGuard(隧道内的目标地址)。每个负载均衡器的健康探测也会拿到。
katana src/runtime.rs → build_outbounds,在保留的 direct/block 处理器之前 每个出站池一个 内置的 direct/freedom 处理器和每个配置的 [[outbound]]。所有节点共享同一个池,因此也共享同一个解析器。

对贡献者的影响:

  • 重载后缓存从空开始。 每次重载成功,app 都会构建新的 generation,同时构建新的解析器。旧 generation 在被释放之前一直保留自己的解析器。build 失败时,旧 generation 连同其已预热的缓存保持不变。重载路径见 Generation 与重载。
  • katana 只随出站池重建解析器。 katana 的 apply_reload 只在 new.outbounds != cfg.outbounds 时调用 build_outbounds。因此只修改 [dns] 时,重载既不会应用也不会校验它。它要等到下次 [[outbound]] 列表变化或重启时才生效,无效的 [dns] 表也要到那时才暴露出来,表现为 reload: bad outbounds, keeping current config: <message>。
  • 默认构造的使用方有私有缓存。 FreedomConnector::default()、TransportConnector::tcp(),以及尚未调用 with_resolver 的 WgConnector 和 Hy2Connector,都以 Resolver::default() 起步,也就是一个带独立缓存的新 system 解析器。在 build/build_outbounds 之外构建的任何东西,都必须通过 FreedomConnector::new、TransportConnector::new 或 with_resolver 显式传入共享解析器,否则它会自行解析。
  • WireGuard 对端 endpoint 不在这里解析。 WgConnector 用共享解析器解析经由隧道访问的目标地址。对端自身的 endpoint 若是名称,由 protocols/src/wireguard/device.rs → resolve_endpoint 通过 tokio::net::lookup_host 查询,也就是不经过这个缓存的主机解析器,并且只使用第一个地址。
  • 解析器直接拨号。 DNS 流量通过 tokio::net::UdpSocket::bind 和 TcpStream::connect 发出,不经过 etemenanki-environment 的拨号器,因此拨号器的 SocketOptions 和连接超时对它不生效。QUERY_TIMEOUT 是它唯一的截止时间。参见 拨号器。
flowchart TB
  start["resolve(host)"] --> lit{"host 能解析为 IpAddr?"}
  lit -- 是 --> retlit["Ok(vec![ip]),不查缓存,不查后端"]
  lit -- 否 --> key["key = host.to_ascii_lowercase()"]
  key --> hit{"已缓存且 expires_at > now?"}
  hit -- 是 --> rethit["Ok(缓存的地址)"]
  hit -- 否 --> be{"后端"}
  be -- System --> gai["lookup_host((host, 0)),ttl 为 None"]
  be -- "Udp、Tls、Https" --> both["query_both:A 和 AAAA"]
  gai --> empty{"没有地址?"}
  both --> empty
  empty -- 是 --> nf["Err NotFound:did not resolve"]
  empty -- 否 --> ttl["ttl.unwrap_or(SYSTEM_TTL),clamp(min_ttl, max_ttl)"]
  ttl --> ins["cache.insert(key, Cached)"]
  ins --> ok["Ok(addresses)"]

图中没有体现的细节:

  • key 转为小写,查询不转。 key 是 host.to_ascii_lowercase(),但 lookup(host) 按调用方原本的拼写发送名称。Example.COM 与 example.com 共用一个条目。
  • 过期按条目计算。 moka 自身的 time_to_live 是整个缓存共用的单一值,只作为 MAX_TTL 处的兜底。真正的截止时间是 Cached.expires_at,resolve 把它与 tokio::time::Instant::now() 比较。过期条目在读取时不会被删除,而是由下一次成功查询覆盖,或由 moka 淘汰。
  • 截止时间不会溢出。 Instant::now().checked_add(ttl) 溢出时退回 Instant::now(),此时条目一写入就已过期,这是安全的失败方式。
  • System 后端会去重并丢弃端口。 它调用 tokio::net::lookup_host((host, 0)),在 tokio 的阻塞线程池上运行 getaddrinfo。该后端丢弃端口,每个 IP 只保留第一次出现,并且不报告 TTL,因此使用 SYSTEM_TTL。它是唯一遵循 /etc/hosts、nsswitch.conf 和搜索域的后端,这也是它保持为默认后端的原因。
  • 失败不缓存。 错误(包括空应答导致的 NotFound)直接返回,不触碰缓存,下一次调用会重新询问后端。
  • 并发未命中不合并。 resolve 先 cache.get 再 cache.insert,而不是 get-or-insert-with。两个调用方同时未命中同一名称时,会各自执行一次查询,后写入的结果生效。
  • 命中返回副本。 缓存中的 Arc<Vec<IpAddr>> 会被克隆成一个新的 Vec 交给调用方。

每个协议后端都用 tokio::join! 发出两个独立的问题,各自有独立的 QUERY_TIMEOUT,各自使用新的随机 16 位 ID(rand::random):

flowchart TB
  qb["query_both(host, server)"] --> join["tokio::join!"]
  join --> ea["encode_query(id_a, host, TYPE_A)"]
  join --> e6["encode_query(id_b, host, TYPE_AAAA)"]
  ea --> xa["timeout(QUERY_TIMEOUT, exchange),独立的 socket 或连接"]
  e6 --> x6["timeout(QUERY_TIMEOUT, exchange),独立的 socket 或连接"]
  xa --> da["decode_answer(id_a, response)"]
  x6 --> d6["decode_answer(id_b, response)"]
  da -- "Ok(Answer) 或 Err" --> merge["先 v4 后 v6 遍历:地址去重,取最小 TTL,保留 last_err"]
  d6 -- "Ok(Answer) 或 Err" --> merge
  merge --> none{"没有地址且有错误?"}
  none -- 是 --> err["Err(last_err)"]
  none -- 否 --> ok["Ok((addresses, ttl))"]

query_both 按 [v4, v6] 的顺序遍历结果:

  • 成功的 Answer 贡献它的地址(跳过重复项)和它的 ttl。合并后的 TTL 取两个应答中的最小值。
  • 失败的查询只把错误记入 last_err。
  • 只有在没有任何地址返回、且至少一个查询失败时,调用才会失败。此时返回 last_err;两个都失败时,它是 AAAA 的错误。只有 A 记录的主机即使 AAAA 查询超时或出错,也能解析成功。
  • 两个查询都成功但都没有记录时,结果是 Ok((vec![], ttl)),resolve 会把它变成 dns: <host> did not resolve(NotFound)。

这种顺序和合并方式带来两个后果:

  • 使用协议后端时,IPv4 地址总是排在 IPv6 地址之前。AddressFamilyStrategy::Auto 保持解析器给出的顺序,因此这类出站会先尝试 IPv4。
  • 一个地址族失败的查询结果照常缓存,在 TTL 到期前一直只有单一地址族。

protocols/src/dns/message.rs 是一个 stub 解析器的 codec:发出一个问题,读回应答区中的地址记录。它对每个偏移量都使用 crate::helpers::parse::{take, take_array} 和带检查的算术,因此恶意响应只会导致错误,绝不会 panic 或越界读取。共享的解析辅助函数和 ProtocolError 见 协议基础。

pub const TYPE_A: u16 = 1;
pub const TYPE_AAAA: u16 = 28;
const CLASS_IN: u16 = 1;
const HEADER_LEN: usize = 12;
const FLAG_QR: u16 = 0x8000;
const FLAG_RD: u16 = 0x0100;
const RCODE_MASK: u16 = 0x000F;
const MAX_POINTER_HOPS: usize = 16;
const MAX_NAME_LEN: usize = 255;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Answer {
pub addresses: Vec<IpAddr>,
pub ttl: Option<Duration>,
}
pub fn encode_query(id: u16, name: &str, qtype: u16) -> io::Result<Vec<u8>>;
pub fn decode_answer(id: u16, data: &[u8]) -> io::Result<Answer>;
fn skip_name(data: &[u8], mut at: usize) -> Result<usize, ProtocolError>;

encode_query 写出一个标准的递归查询。所有整数均为大端序。

字段 长度 写入的值
ID 2 id,每个查询随机生成
Flags 2 FLAG_RD(0x0100):请求递归的标准查询
QDCOUNT 2 1
ANCOUNT、NSCOUNT、ARCOUNT 6 0
QNAME 可变 每个非空标签写一个长度字节加标签字节,最后是值为 0 的根标签
QTYPE 2 TYPE_A(1)或 TYPE_AAAA(28)
QCLASS 2 CLASS_IN(1)

编码规则:

  • 名称长度。 文本形式的名称最多 MAX_NAME_LEN(255)字节,否则调用以 dns: name too long (<n> bytes) 失败。
  • 标签长度。 每个标签最多 63 字节,否则调用以 dns: label longer than 63 失败。
  • 空标签。 编码器会丢弃空标签,所以 example.com. 和 example.com 的编码完全相同。
  • 不做转义,也不做 IDNA。 编码器按原样复制标签字节。
  • 不使用 EDNS。 查询中没有 OPT 记录,因此 UDP 响应受限于传统的 512 字节。

decode_answer 读取报头,跳过问题区,然后读取 ANCOUNT 条资源记录。它从不读取授权区和附加区。

字段 长度 检查
ID 2 必须等于查询的 id,否则为 dns: response id <got> does not match query <id>(InvalidData)
Flags 2 必须置位 QR(0x8000),否则为 dns: not a response(InvalidData)。RCODE(低 4 位)必须为 0,否则为 dns: server returned rcode <n>(NotFound)。
QDCOUNT 2 要跳过的问题数
ANCOUNT 2 要读取的记录数
NSCOUNT、ARCOUNT 4 不读取
问题 × QDCOUNT 可变 skip_name,再跳过 4 字节的 QTYPE 和 QCLASS
RR NAME 可变 skip_name
RR TYPE 2 使用 TYPE_A 或 TYPE_AAAA,其他类型跳过
RR CLASS 2 不检查
RR TTL 4 单位为秒;所有 A 和 AAAA 记录中的最小值成为 Answer.ttl
RDLENGTH 2 RDATA 范围必须位于缓冲区内,否则为 truncated input: dns rdata
RDATA RDLENGTH A 为 4 字节,AAAA 为 16 字节;其他长度跳过

记录循环有意设计得宽松:

  • 无法使用的记录会被跳过,而不是致命错误。 CNAME、未知类型,或 RDLENGTH 不对的 A 记录,都用 at = rdata_at + rdlen 跳过,然后读取下一条记录。典型的应答会在地址之前带一条 CNAME,它必须仍能解析成功。
  • 每条 A 或 AAAA 记录都参与 TTL 计算,包括因长度错误而被跳过的记录。
  • 结果可以为空。 Answer.addresses 可能为空,此时 ttl 可能有值也可能为 None。空结果如何处理由上一层的 query_both 和 resolve 决定。

报头、问题区或记录边界上的结构性错误会使整个响应失败。每个错误都是一个转换为 io::Error 的 ProtocolError:Truncated 转为 UnexpectedEof,Overflow 转为 InvalidData。

解码器从不需要名称的文本,只需要知道名称在哪里结束,所以 skip_name 原地遍历名称,从不跟随压缩指针:

标签首字节 含义 skip_name 的处理
0x00 根标签 返回其后的偏移量
0b11xx_xxxx 压缩指针(2 字节) 返回这 2 字节指针之后的偏移量,不解引用
0b01xx_xxxx 或 0b10xx_xxxx 保留的标签类型 以 truncated input: dns label type 失败
0b00xx_xxxx(1 到 63) 字面标签 前进 1 加标签长度(带检查),然后继续循环

循环最多执行 MAX_POINTER_HOPS(16)次,每个标签一次。由于指针会结束遍历且从不被跟随,指针环不可能导致死循环。这个上限限制的是原地读取的标签数:一个在终止符前写有超过 15 个字面标签的名称会以 truncated input: dns name pointer chain 失败,整个响应随之失败。服务器回显的问题也在此列,所以查询含 16 个或更多标签的名称时,无法通过协议后端解析。

query 用 tokio::time::timeout(QUERY_TIMEOUT, ..) 包裹一次交换。超时覆盖从创建 socket 到读完最后一个字节的全过程,包括 TCP 连接和 TLS 握手。超时后的错误是 dns: query timed out(TimedOut)。

exchange_udp:

  1. 新建一个 UdpSocket,绑定到 0.0.0.0:0 或 [::]:0,地址族与服务器相同,以便访问 IPv6 解析器。
  2. connect(server),让内核只投递来自服务器地址和端口的数据报。
  3. send 查询一次。
  4. recv 一个数据报,放入 UDP_BUF(512)字节的缓冲区,并截断到实际接收的长度。

没有重传。丢失的数据报在 QUERY_TIMEOUT 后表现为 dns: query timed out。不检查 TC 位,也不会改用 TCP 重试。被截断的响应按收到的样子解码:其中仍完整的记录照常使用,在某条记录中间结束的响应会以 truncated input 错误失败。长于 512 字节的数据报会被接收缓冲区截成 512 字节。

DoT 和 DoH 后端不做任何连接池。每次 query 都打开自己的 TCP 连接,完成自己的 TLS 握手。由于 A 和 AAAA 并发执行,一次缓存未命中要花费两个连接。以 DoT 为例:

sequenceDiagram
  participant A as query(TYPE_A)
  participant B as query(TYPE_AAAA)
  participant S as DoT 服务器
  A->>S: TcpStream::connect,TLS 握手(SNI 为 server_name)
  B->>S: TcpStream::connect,TLS 握手(SNI 为 server_name)
  A->>S: u16 长度 + A 查询
  B->>S: u16 长度 + AAAA 查询
  S-->>A: u16 长度 + 响应
  S-->>B: u16 长度 + 响应
  Note over A,B: 每个流收到一个响应后即被丢弃

DoH 的结构相同,只是用 POST 请求代替长度前缀,用 read_to_end 代替按长度分隔的读取。它们真正共享的是 Inner.tls 中的 SslConnector,它在 Resolver::new 中只构建一次。源码给出的理由是:缓存预热后很少需要查询解析器,而且保持打开的连接反正也需要重新验证。如果你要加入连接池,请让整个交换仍然处于 QUERY_TIMEOUT 之内,并保证每个请求 ID 只对应一个响应。

Resolver::new 调用 protocols/src/transports/tls/config.rs → ClientConfig::with_verify_mode:

pub fn with_verify_mode(
sni: impl AsRef<str>,
verify_mode: VerifyMode,
alpn: Alpn,
) -> io::Result<Self>;
后端 sni verify_mode alpn
Tls server_name 设置了 ca_pem 时为 VerifyMode::CustomCa(pem),否则为 VerifyMode::System Alpn::None
Https URL 中的 host 同上 Alpn::Http1

两种模式都会校验证书链和主机名,都要求 TLS 1.2 或更高版本。解析器从不使用 VerifyMode::Insecure。ClientConfig::wrap 把名称交给 OpenSSL 的 into_ssl,因此能解析为 IP 地址的名称(例如 192.0.2.53 这样的 server_name,或 IPv4 形式的 URL host)发送时不带 SNI,并按证书中的 IP 地址条目校验。

VerifyMode::CustomCa 在系统根证书之外额外信任所配置的 CA。它先调用 set_default_verify_paths(),再把从 PEM 中解析出的每张证书加入 connector 自己的证书库;主机的信任库不会被修改。因此使用自有 CA 的私有解析器可以正常工作,公共解析器也仍按系统根证书校验。PEM 中没有任何证书时,构建以 no certificate in CA PEM bundle 失败。

不变量 由谁保证 由哪个测试固定
协议后端询问的是配置的服务器 传给 exchange_udp 的 Backend::Udp(addr) the_udp_backend_resolves_against_the_configured_server
DoT 使用 2 字节长度分帧,DoH 使用 RFC 8484 POST,两者都经过已校验的握手 exchange_tls、exchange_https_over dot_resolves_over_tls、doh_resolves_over_https(DoH 测试服务器会断言请求行、Content-Type 和 Host)
地址字面量永远不会到达后端或缓存 resolve 中 host.parse::<IpAddr>() 的提前返回 an_address_literal_never_reaches_the_backend
TTL 内的重复查询由缓存应答 Cached.expires_at 与 Instant::now() 的比较 a_second_lookup_is_served_from_cache
过期条目会重新查询,而不是返回陈旧结果 同一个比较 an_expired_entry_is_looked_up_again(使用 with_ttl_bounds)
克隆共享同一个缓存 Resolver { inner: Arc<Inner> } a_clone_shares_the_same_cache
无论客户端发送多少不同名称,缓存都有上限 max_capacity(CACHE_CAPACITY) the_cache_stays_bounded_under_distinct_names;它插入 CACHE_CAPACITY / 4 + 500 个名称,少于容量,所以即使没有上限也能通过,并未覆盖淘汰逻辑
没有得到任何地址的查询是错误,而不是 Ok(vec![]) query_both 中的 last_err 返回,以及 resolve 中的 addresses.is_empty() 检查 a_name_that_does_not_resolve_is_an_error_not_an_empty_answer;它让解析器指向一个不可达的服务器,所以固定的是两个查询都失败的情形
默认后端是主机解析器 impl Default for Resolver → Resolver::system() the_default_backend_is_the_host_resolver
缺少必需字段的后端被拒绝,绝不会被替换成 system from_spec 中的 needed 闭包和 ok_or_else 检查 a_backend_missing_its_fields_is_refused;端到端测试为 a_backend_without_its_server_is_rejected
DoH URL 只产出 host 和 path;URL 端口和 userinfo 永远不会进入被校验的名称 Backend::https a_doh_url_is_split_into_authority_and_path
响应必须携带查询的 ID、置位 QR,且 RCODE 为 0 decode_answer 的报头检查 rejects_a_mismatched_id_a_question_and_an_error_rcode
截断或畸形的输入返回错误,绝不 panic 每个偏移量上的 take、take_array 和 checked_add truncated_and_malformed_input_never_panics
压缩指针环能够终止 skip_name 从不跟随指针,且循环受 MAX_POINTER_HOPS 限制 a_compression_pointer_loop_terminates
一条无法识别的记录不会导致丢弃同一应答中的地址 记录循环中的 _ => {} 分支 an_unrecognised_record_is_skipped_not_fatal
以最短的地址 TTL 为准 对 A 和 AAAA 的 TTL 取 min decodes_addresses_and_the_smallest_ttl
查询格式正确且有上限 encode_query 的检查 a_query_round_trips_through_its_own_encoder、a_trailing_dot_encodes_the_same_as_without、rejects_an_over_long_name_and_label
DoH 服务器返回的 HTTP 错误是错误,而不是应答 exchange_https_over 中的状态行检查 doh_surfaces_an_http_error
固定到错误 CA 的解析器会握手失败 VerifyMode::CustomCa 下的证书链校验:服务器证书既不链到固定的 CA,也不链到任何系统根证书 a_resolver_pinned_to_the_wrong_ca_is_refused
app 实际使用的就是配置的后端 build 中唯一的一次 Resolver::from_spec,结果传给每个出站 the_udp_backend_is_used_for_resolution,以及它的反向对照 the_default_backend_does_not_know_the_test_name
一个地址族失败不会丢弃另一个地址族 只有在没有地址返回时才返回 last_err the_udp_backend_is_used_for_resolution:它的假服务器对 A 返回一个地址,对 AAAA 返回 NXDOMAIN(RCODE 3)

有三种行为没有专门的测试:

  • 两个成功但都没有地址记录的应答。 所有假服务器都会对 A 返回地址,而负向测试让两个查询都失败,所以“两个查询都成功但为空、resolve 返回 dns: <host> did not resolve”这条路径只在生产环境中被执行到。
  • DoH 的 Connection: close 头。 DoH 测试服务器断言请求行、Content-Type 和 Host,但不断言 Connection。
  • 大小写不敏感的缓存 key。 没有测试用两种拼写解析同一个名称。

resolve 返回 io::Result,resolve_candidates 等调用方会把错误原样向上传递,之后连接如何处理由出站决定。下列消息除 ProtocolError 和 TLS 相关的之外,都以 dns: 开头。

阶段 消息 io::ErrorKind
构建 dns: the udp backend needs a server address(tls、https 同理) InvalidInput
构建 dns: invalid server address: <parse error> InvalidInput
构建 dns: the tls backend needs a server name to verify against InvalidInput
构建 dns: the https backend needs the resolver's url InvalidInput
构建 dns: unknown backend "<name>" (expected "system", "udp", "tls" or "https") InvalidInput
构建 dns: "<url>" is not an https:// url、dns: "<url>" has no usable host InvalidInput
构建 no certificate in CA PEM bundle InvalidInput
构建 来自 ClientConfig::with_verify_mode 的 OpenSSL 错误,例如无法解析的证书块 Other(ossl_err)
解析 dns: <host> did not resolve NotFound
解析(System) 主机解析器的错误,由 tokio::net::lookup_host 原样传出 与标准库返回的一致
查询 dns: name too long (<n> bytes)、dns: label longer than 63 InvalidInput
查询 dns: query timed out TimedOut
查询 socket、连接或 TLS 错误 与 tokio 和 OpenSSL 返回的一致
解码 dns: response id <got> does not match query <id>、dns: not a response InvalidData
解码 dns: server returned rcode <n>(NXDOMAIN 为 3,SERVFAIL 为 2) NotFound
解码 truncated input: dns name、truncated input: dns label type、truncated input: dns name pointer chain、truncated input: dns rdata、truncated input: fixed-size field UnexpectedEof
解码 integer overflow: dns name、integer overflow: dns rr 等 InvalidData
DoH dns: no http header、dns: doh server answered "<status line>" InvalidData

app 在 --test 下把构建错误记录为 configuration invalid: <message>,启动时记录为 failed to start: <message>,然后以失败状态退出。重载时记录 reload: build failed, keeping current config: <message>。katana 的 build_outbounds 把这些错误作为出站池构建的 io::Error 返回。启动时 katana 记录 failed to build outbounds: <message> 并以失败状态退出;它的 --test 会向标准错误输出 configuration error: <message>。在会重建出站池的重载中,它记录 reload: bad outbounds, keeping current config: <message>,当前的出站池保持不变。

取消。 解析器不 spawn 任何任务。resolve 是一个普通的 future:丢弃它就会丢弃两个查询的 join!,连同其中的 socket、TLS 流和定时器。在完成前被取消的查询不会向缓存插入任何内容。moka 的 future::Cache 在 get 和 insert 调用内部完成维护工作,所以不存在比 generation 活得更久的后台 worker。当 generation 的出站和探测所持有的最后一个克隆被释放时,解析器随之销毁。

超时范围。 QUERY_TIMEOUT 作用于每个协议查询,而不是整个 resolve。A 和 AAAA 查询并发执行,所以一次协议查询最多耗时约 QUERY_TIMEOUT。System 后端没有被 QUERY_TIMEOUT 包裹,耗时取决于主机解析器自身的超时和重试设置。

名称 值 位置 作用
CACHE_CAPACITY 8192 个条目 dns/mod.rs moka 的 max_capacity;key 是客户端选择的名称,所以缓存必须有上限
MIN_TTL 5 秒 dns/mod.rs 所有 TTL 的下限,使 TTL 为零的应答也会被短暂缓存
MAX_TTL 3600 秒 dns/mod.rs 所有 TTL 的上限,也是 moka 整个缓存的 time_to_live 兜底值
SYSTEM_TTL 60 秒 dns/mod.rs 为不报告 TTL 的 getaddrinfo 应答假定的 TTL
QUERY_TIMEOUT 5 秒 dns/mod.rs 每个 A 或 AAAA 查询的时限,从创建 socket 到最后一个字节
UDP_BUF 512 字节 dns/mod.rs UDP 接收缓冲区;不使用 EDNS
HEADER_LEN 12 字节 dns/message.rs 固定的 DNS 报头
MAX_NAME_LEN 255 字节 dns/message.rs encode_query 接受的最长名称(文本长度)
标签长度 63 字节 dns/message.rs → encode_query 单个标签的最大长度
MAX_POINTER_HOPS 16 dns/message.rs skip_name 的迭代次数;终止符前最多 15 个字面标签
DoT 长度前缀 u16 dns/mod.rs → exchange_tls DoT 消息最大 65535 字节
TLS 版本 1.2 及更高 transports/tls/config.rs 由 with_verify_mode 设置
文件 覆盖内容
protocols/tests/unit/dns/mod.rs 针对进程内一个会统计查询次数的 UDP 服务器测试 Resolver:字面量、缓存命中、克隆共享、容量上限、过期、不可达的服务器、from_spec 的拒绝、Backend::https 的 URL 拆分,以及默认后端解析 localhost
protocols/tests/unit/dns/message.rs codec:往返编解码、结尾的点、A 和 AAAA 解码、最小 TTL、跳过 CNAME、ID/QR/RCODE 的拒绝、每一个截断点、自引用指针,以及过长的名称和标签
protocols/tests/pipeline/dns_secure.rs 针对真实 OpenSSL 服务器测试 DoT 和 DoH,服务器使用以 ca_pem 传入的自签名证书:dot_resolves_over_tls、doh_resolves_over_https、doh_surfaces_an_http_error、a_resolver_pinned_to_the_wrong_ca_is_refused
app/tests/integration/e2e_dns.rs 以 SOCKS 入站运行构建好的 etemenanki-app 二进制:只有假 UDP 服务器认识的名称在 backend = "udp" 下能解析(服务器对其 AAAA 查询返回 NXDOMAIN,所以也覆盖了一个地址族失败的情形),在默认后端下解析失败;缺少 server 的 backend = "udp" 无法通过 --test

单元测试通过 #[path] 模块编译进库中,所以随 crate 的 lib 测试一起运行:

终端窗口
cargo test -p etemenanki-protocols --lib dns::
cargo test -p etemenanki-protocols --test pipeline dns_secure
cargo test -p etemenanki-app --test integration e2e_dns

an_expired_entry_is_looked_up_again 使用真实时间而不是暂停的时钟。暂停的运行时在等待真实 socket 时会自动推进时间,会在 TTL 到期之前先触发 QUERY_TIMEOUT。把 DNS 测试改为 tokio::time::pause 之前请记住这一点。

修改 codec 时,请在 truncated_and_malformed_input_never_panics 旁边增加一个畸形输入用例。修改后端时,请扩展 dns_secure.rs:只有在真实服务器上,分帧、握手和报头处理才会被一起执行到。