DNS 解析器
源码文件:25 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
Etemenanki/protocols/src/dns/mod.rsEtemenanki/protocols/src/dns/message.rsEtemenanki/protocols/src/error.rsEtemenanki/protocols/src/helpers/parse.rsEtemenanki/protocols/src/helpers/address_family.rsEtemenanki/protocols/src/transports/tls/config.rsEtemenanki/protocols/src/transports/connect.rsEtemenanki/protocols/src/hysteria/connector.rsEtemenanki/protocols/src/wireguard/connector.rsEtemenanki/protocols/src/wireguard/device.rsEtemenanki/app/src/config.rsEtemenanki/app/src/instance.rsEtemenanki/app/src/main.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/outbound/freedom.rsEtemenanki/app/src/balancer.rsEtemenanki/protocols/tests/unit/dns/mod.rsEtemenanki/protocols/tests/unit/dns/message.rsEtemenanki/protocols/tests/pipeline/dns_secure.rsEtemenanki/app/tests/integration/e2e_dns.rsEtemenanki/app/tests/support/mod.rsEtemenanki/protocols/tests/support/mod.rskatana/src/config.rskatana/src/runtime.rskatana/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 能力。解析器也不做任何路由决策,从不为路由器解析名称。它只服务于出站和负载均衡器的健康探测。
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 常量列在一起。
ResolverSpec 与 from_spec
Section titled “ResolverSpec 与 from_spec”#[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,且都不会退回主机解析器:运维者指定了某个解析器,就不能在不告知的情况下换成另一个。错误文本列在下文的“失败路径”一节。
Backend
Section titled “Backend”#[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 解析库:
- URL 必须以
https://开头,否则以dns: "<url>" is not an https:// url拒绝。 - authority 延伸到第一个
/为止,其余部分是请求路径。没有/时,路径为/dns-query。 - authority 为空或包含
@时,以dns: "<url>" has no usable host拒绝。否则 userinfo 会混进被校验的名称里。 - authority 中第一个
:之后的内容全部丢弃。实际拨号的地址是server,所以 URL 中的端口在这里没有意义,也不能混进证书名称。
host 有两个用途:作为要校验的 TLS 服务器名称,以及作为 HTTP Host 头。由于只是简单地在 : 处拆分,带方括号的 IPv6 字面量不能用作 URL authority:https://[2001:db8::53]/dns-query 可以构建,但 host 会变成 [2001,没有任何证书能匹配。请在 URL 中使用 DNS 名称,把地址写在 server 里。
Resolver、Inner 与 Cached
Section titled “Resolver、Inner 与 Cached”#[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 moduleasync 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>>;每个 generation 一个解析器
Section titled “每个 generation 一个解析器”只有解析重叠名称的调用方共享同一个缓存,缓存才有意义。因此两个程序都为每套出站只构建一个解析器,再把它的克隆交给所有需要解析的地方。
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是它唯一的截止时间。参见 拨号器。
resolve:先查缓存,再查后端
Section titled “resolve:先查缓存,再查后端”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交给调用方。
query_both:并发查询 A 和 AAAA
Section titled “query_both:并发查询 A 和 AAAA”每个协议后端都用 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 到期前一直只有单一地址族。
报文 codec
Section titled “报文 codec”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。
名称与压缩指针
Section titled “名称与压缩指针”解码器从不需要名称的文本,只需要知道名称在哪里结束,所以 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:
- 新建一个
UdpSocket,绑定到0.0.0.0:0或[::]:0,地址族与服务器相同,以便访问 IPv6 解析器。 connect(server),让内核只投递来自服务器地址和端口的数据报。send查询一次。recv一个数据报,放入UDP_BUF(512)字节的缓冲区,并截断到实际接收的长度。
没有重传。丢失的数据报在 QUERY_TIMEOUT 后表现为 dns: query timed out。不检查 TC 位,也不会改用 TCP 重试。被截断的响应按收到的样子解码:其中仍完整的记录照常使用,在某条记录中间结束的响应会以 truncated input 错误失败。长于 512 字节的数据报会被接收缓冲区截成 512 字节。
exchange_tls 使用 RFC 7858 中的 DNS-over-TCP 分帧:
| 字段 | 长度 | 含义 |
|---|---|---|
| Length | 2 | 后续消息的长度,大端序 |
| Message | Length | DNS 查询或响应 |
TcpStream::connect(server),然后Inner.tls.wrap(tcp):进行 TLS 握手,SNI 和证书名称均为server_name,不使用 ALPN(Alpn::None)。IP 字面量形式的server_name按下文 TLS 一节的方式处理。- 写入 2 字节长度和查询,然后 flush。超过 65535 字节的查询会以
dns: query too long失败,但encode_query不可能生成这样的查询。 read_exact读取 2 字节长度,再read_exact读取恰好这么多字节。这一条消息就是响应。- 丢弃该流。每个连接只承载一次查询。
exchange_https 的连接和包装方式与 DoT 完全相同,只是它的 ClientConfig 把 http/1.1 作为唯一的 ALPN 协议(Alpn::Http1)。源码中说明的意图是:不肯使用 HTTP/1.1 的服务器应当拒绝握手,而不是以客户端无法读取的形式应答。客户端不检查服务器选择了哪个协议,也不检查是否选择了协议。随后 exchange_https_over 发送一个 RFC 8484 POST 请求:
POST {path} HTTP/1.1Host: {host}Accept: application/dns-messageContent-Type: application/dns-messageContent-Length: {query.len()}Connection: close
{query bytes}响应处理非常精简:
read_to_end一直读到服务器关闭连接。请求已通过Connection: close要求服务器这样做。- 查找第一个
\r\n\r\n。找不到时,错误为dns: no http header。 - 取状态行中以空白分隔的第二个 token。不是
200时以dns: doh server answered "<status line>"(InvalidData)失败,所以错误页永远不会被当作 DNS 解码。 - 把空行之后的所有字节原样交给
decode_answer。
客户端不解释任何其他响应头:不看 Content-Length,不看 Content-Type,也不看 Transfer-Encoding。它期望的是以 EOF 结束的普通响应体。如果服务器仍然保持连接不关闭,查询会一直等到 QUERY_TIMEOUT。
每个查询一个连接
Section titled “每个查询一个连接”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 只对应一个响应。
TLS 配置与 CA 处理
Section titled “TLS 配置与 CA 处理”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。 没有测试用两种拼写解析同一个名称。
失败路径与取消
Section titled “失败路径与取消”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_securecargo test -p etemenanki-app --test integration e2e_dnsan_expired_entry_is_looked_up_again 使用真实时间而不是暂停的时钟。暂停的运行时在等待真实 socket 时会自动推进时间,会在 TTL 到期之前先触发 QUERY_TIMEOUT。把 DNS 测试改为 tokio::time::pause 之前请记住这一点。
修改 codec 时,请在 truncated_and_malformed_input_never_panics 旁边增加一个畸形输入用例。修改后端时,请扩展 dns_secure.rs:只有在真实服务器上,分帧、握手和报头处理才会被一起执行到。