名称解析与 DNS 服务
源码文件:33 个 · 核对版本 Etemenanki 555b7df · katana v4.1.1
Etemenanki/supervisor/src/build/dns.rsEtemenanki/supervisor/src/topology/outbound/dns.rsEtemenanki/supervisor/src/topology/spec_plan/dns.rsEtemenanki/supervisor/src/topology/spec_plan/mod.rsEtemenanki/supervisor/src/topology/spec_plan/plan.rsEtemenanki/supervisor/src/topology/plane.rsEtemenanki/supervisor/src/topology/outbound/mod.rsEtemenanki/supervisor/src/topology/outbound/udp_fanout.rsEtemenanki/supervisor/src/topology/flow.rsEtemenanki/supervisor/src/connector.rsEtemenanki/supervisor/src/supervisor.rsEtemenanki/supervisor/src/build/outbound.rsEtemenanki/supervisor/src/build/apply.rsEtemenanki/supervisor/src/build/validate.rsEtemenanki/supervisor/src/entity/id.rsEtemenanki/protocols/src/dns/mod.rsEtemenanki/protocols/src/dns/serve.rsEtemenanki/protocols/src/dns/hosts.rsEtemenanki/app/src/lower.rsEtemenanki/app/src/subscribe.rsEtemenanki/app/src/instance.rsEtemenanki/app/src/main.rsEtemenanki/subscribe/src/dns.rsEtemenanki/supervisor/tests/unit/dns_outbound.rsEtemenanki/supervisor/tests/unit/plane.rsEtemenanki/supervisor/tests/unit/plan.rsEtemenanki/protocols/tests/unit/dns/mod.rsEtemenanki/protocols/tests/unit/dns/serve.rsEtemenanki/app/tests/unit/lower.rsEtemenanki/app/tests/unit/subscribe.rsEtemenanki/app/tests/integration/e2e_dns.rskatana/src/lower/outbound.rskatana/src/lower/mod.rs
supervisor(监管器)为三类工作解析名称:找到出站要拨号的代理服务器,找到它自己要连接的目的地,以及在客户端上应答应用程序经由它发出的 DNS 查询。spec(期望状态)中的一个字段 Spec::dns 描述了这三者。supervisor/src/build/dns.rs → build_dns 把它变成一个 Dns 值:一个用于代理服务器的解析器、一个用于目的地的解析器,以及一个可选的 DNS 服务。服务存在时,plane(数据平面)把应用程序发往 53 端口的每个流都交给它。spec 指定了要经由代理访问的服务器时,解析器向这些服务器发出的查询和其他流量一样,经由路由表离开本机。
本页面向这样的贡献者:修改 supervisor 构建解析器的方式、哪个使用方拿到哪个解析器、DNS 服务及其出站,或者 RoutedDialer。解析器本身(后端、缓存、查询和报文 codec)见 DNS 解析器。运维者视角下 app 的 [dns] 表,见用户指南的 DNS 页面。
| 组件 | 文件 → 符号 | 负责 |
|---|---|---|
| 期望状态 | supervisor/src/topology/spec_plan/dns.rs → DnsSpec |
名称解析的两种形态 Single 和 Split,以可比较的值表示 |
| 构建 | supervisor/src/build/dns.rs → build_dns、Dns |
构建 spec 描述的解析器,以及基于其中一个解析器的服务 |
| 应答 | protocols/src/dns/serve.rs → Service |
决定每个应用程序查询得到什么:一个中继得到的响应、一个它自己给出的应答,或者什么都没有 |
| 服务出站 | supervisor/src/topology/outbound/mod.rs → Outbound::Dns;supervisor/src/topology/outbound/dns.rs → DnsUdpLink、DnsTcpStream |
把应用程序发往 53 端口的流送到服务:UDP 上每个数据报一个查询,TCP 上是带长度前缀的报文 |
| 拦截 | supervisor/src/topology/plane.rs → Plane::route、DNS_PORT |
服务存在时,把应用程序发往 53 端口的每个流都送到服务 |
| 解析器出口 | supervisor/src/topology/outbound/dns.rs → RoutedDialer;Plane::route_upstream |
把经代理的解析器的连接作为经由路由表的流打开,路由表从不拦截它们 |
| 生命周期 | supervisor/src/supervisor.rs → Actor::prepare、Actor::commit;supervisor/src/topology/spec_plan/plan.rs → plan |
只在 DnsSpec 变化时重建解析器,以及持有它们的每个出站 |
它交给别处的事:
- 解析。 查询、缓存、TTL 限制、按顺序尝试服务器以及中继原始查询,都在
etemenanki_protocols::dns中。见 DNS 解析器。 - 路由决策。
Plane::route_upstream询问编译后的路由表。规则如何匹配见 plane:为每个流选路和路由模型。 - 使用解析器。 每个出站用构建时拿到的解析器解析自己的名称。出站如何拨号见出站、UDP fan-out 与负载均衡器。
- 编写 spec。 前端程序把各自的格式降为
DnsSpec,这一步称为 lowering(降为 spec)。见 DnsSpec 从哪里来。
DnsSpec
Section titled “DnsSpec”#[derive(Debug, Clone, PartialEq, Eq)]pub enum DnsSpec { Single(Backend), Split { pre_proxy: Vec<Backend>, through_proxy: Vec<Backend>, use_hosts: bool, resolve_ipv6: bool, },}DnsSpec 是 Spec(supervisor/src/topology/spec_plan/mod.rs)的 dns 字段。它持有 etemenanki_protocols::dns::Backend 值,而不是解析出这些值的字符串。Backend derive 了 PartialEq,所以比较的是服务器地址、TLS 服务器名、DoH 的 host 和 path,以及它固定(pin)的 CA 的字节。只要其中任何一项不同,两个 spec 就是不同的 spec。规划器在每次应用(apply)时都依赖这种相等性(见多次应用之间)。
| 变体 | 字段 | 含义 |
|---|---|---|
Single(backend) |
backend |
所有事情用一个解析器:出站拨号的代理服务器,以及本机自己访问的目的地。没有 DNS 服务,应用程序的 DNS 和其他流一样被路由。 |
Split |
pre_proxy |
从本机直接询问的服务器。它们查询代理服务器,以及 through_proxy 服务器的名称。按顺序尝试。 |
through_proxy |
经由路由表访问的服务器。它们查询其余一切,包括应用程序发出的每个查询。按顺序尝试。列表为空表示这些查询也由 pre_proxy 服务器应答。 |
|
use_hosts |
在询问任何服务器之前,先用系统 hosts 文件应答。 | |
resolve_ipv6 |
应用程序是否能得到 AAAA 应答。解析器自己为出站和健康探测所做的查询不受影响。 |
Dns 与 build_dns
Section titled “Dns 与 build_dns”#[derive(Clone)]pub(crate) struct Dns { /// Looks up the proxy servers the outbounds dial. pub servers: Resolver, /// Looks up the destinations this process connects to itself: through /// `freedom`, and inside a WireGuard tunnel. pub destinations: Resolver, /// Answers applications' DNS, when the spec says the supervisor does. pub service: Option<Service>,}
pub(crate) fn build_dns( spec: &DnsSpec, plane: Weak<ArcSwap<Plane>>, socket: &SocketOptions,) -> io::Result<Dns>;这两项都是 crate 私有的。调用方只能看到它们的效果:一个 spec 构建出的出站、健康探测和 plane。Dns 的文档注释写明了规划器遵循的规则:除 blackhole 外的每个出站都持有其中一个解析器,所以 spec 一变,它们就全部重建。
| 参数 | 类型 | 作用 |
|---|---|---|
spec |
&DnsSpec |
期望的解析方式 |
plane |
Weak<ArcSwap<Plane>> |
指向 supervisor 的 plane cell 的弱引用。只有 Split spec 的经代理解析器通过 RoutedDialer 使用它。 |
socket |
&SocketOptions |
supervisor 的 socket 策略。每个从本机打开 socket 的解析器都在这个策略下打开 socket。 |
socket 策略通过 SupervisorBuilder::socket_options 设置一次。它的文档注释说明,它在 supervisor 的整个生命周期内有效,一次应用无法改变它。它覆盖解析器从本机发出的每个查询,但不覆盖系统解析器:getaddrinfo 在 C 库内部打开 socket,任何策略都管不到那里。
Resolver 和 Service 的克隆开销很小:内部各是一个 Arc。servers 和 destinations 可以是同一个解析器的克隆,这时它们共享一个缓存。build_dns 从不调用 Resolver::with_ttl_bounds,所以它构建的每个解析器都用解析器的默认上下限来限制 TTL。
Single
Section titled “Single”let resolver = match backend { Backend::System => Resolver::system(), backend => Resolver::with_upstreams( vec![backend.clone()], ResolverOptions { socket: socket.clone(), ..ResolverOptions::default() }, )?,};Ok(Dns { servers: resolver.clone(), destinations: resolver, service: None })同一个解析器同时充当 servers 和 destinations。代码注释给出了原因:各出站要解析的名称集合相互重叠,而共享一个缓存正是设置解析器的意义所在。
Backend::System变成Resolver::system(),即带缓存的主机解析器。getaddrinfo在 C 库内部打开 socket,所以 socket 策略管不到它。getaddrinfo也会自己读取/etc/hosts。- 其他任何后端都变成一个只有一台服务器的解析器,受 socket 策略约束,没有 hosts 表、没有拨号器,也没有 bootstrap。因此这里不能使用以名称写出的服务器。
Resolver::with_upstreams会拒绝它,报dns: server "<host>" is a name, and nothing resolves it; write its address。app 和 katana 从不产生这样的 spec:它们解析[dns]时把server当作 socket 地址读取(见 DnsSpec 从哪里来)。 service为None,所以 plane 不拦截任何流。
let hosts = match use_hosts { true => Some(Arc::new(Hosts::system()?)), false => None,};let direct = Resolver::with_upstreams( pre_proxy.clone(), ResolverOptions { hosts: hosts.clone(), socket: socket.clone(), ..ResolverOptions::default() },)?;let proxied = if through_proxy.is_empty() { direct.clone()} else { Resolver::with_upstreams( through_proxy.clone(), ResolverOptions { hosts, dialer: Some(Arc::new(RoutedDialer::new(plane))), bootstrap: Some(direct.clone()), socket: socket.clone(), }, )?};Ok(Dns { service: Some(Service::new(proxied.clone(), *resolve_ipv6)), servers: direct, destinations: proxied,})代码最多构建两个解析器,顺序如下:
-
hosts 表。 开启
use_hosts时,protocols/src/dns/hosts.rs→Hosts::system读取/etc/hosts(Windows 上是C:\Windows\System32\drivers\etc\hosts)。文件不存在时得到一张空表,地址无法解析的行会被跳过。其他任何读取错误都会让构建失败。两个解析器通过一个Arc共享同一张表。 -
direct,基于pre_proxy服务器。它有 hosts 表和 socket 策略,但没有拨号器和 bootstrap,所以它的服务器必须写成地址。它可以包含system。 -
proxied,基于through_proxy服务器:dialer是基于 plane cell 的RoutedDialer,所以这个解析器建立的每个连接都是一个经由路由表的流。普通 DNS 服务器于是通过 TCP 询问(RFC 7766),因为只要有拨号器,解析器就通过 TCP 发送普通 DNS。bootstrap是direct,所以through_proxy服务器可以写成名称。它的名称由pre_proxy服务器从本机查询。- 这里拒绝
system:dns: the system resolver cannot be reached through a proxy。getaddrinfo接受的是名称而不是连接,没有什么可以路由。 - socket 策略也会传入,但有拨号器时这个解析器不会自己打开 socket。它的连接由路由表选中的那个出站打开。每个出站都用 supervisor 唯一的那份 socket 策略构建(
build_outbound(outbound, &dns, &self.socket)),所以连接在该策略下离开本机;对 WireGuard 而言,则在隧道内离开。
through_proxy为空时,proxied是direct的克隆,共享它的缓存。 -
Service::new(proxied, resolve_ipv6),应用程序的查询由这个服务应答。
Dns 字段 |
Split 下的值 |
解析什么 |
|---|---|---|
servers |
direct |
代理服务器的名称,不经过任何路由 |
destinations |
proxied |
目的地名称,由 through_proxy 服务器应答;该列表为空时由 pre_proxy 应答 |
service |
Some(Service::new(proxied, resolve_ipv6)) |
应用程序的查询 |
两个列表都为空时,direct 完全没有服务器。Resolver::with_upstreams 接受这种情况:这样的解析器只能用 hosts 表和地址字面量应答。其他名称都会以 dns: <host> is not in the hosts file and no server is configured 失败,服务对这类名称的 A 或 AAAA 问题应答 NXDOMAIN。
build_dns 用到 etemenanki_protocols::dns 的以下各项。解析器如何使用它们见 DNS 解析器。
pub struct ResolverOptions { pub hosts: Option<Arc<Hosts>>, pub dialer: Option<Arc<dyn StreamDialer>>, pub bootstrap: Option<Resolver>, pub socket: SocketOptions,}
pub trait StreamDialer: Send + Sync { fn dial(&self, server: SocketAddr) -> DialFuture;}
pub type BoxedDnsStream = Pin<Box<dyn DnsStream>>;pub type DialFuture = Pin<Box<dyn Future<Output = io::Result<BoxedDnsStream>> + Send>>;
impl Resolver { pub fn system() -> Self; pub fn with_upstreams(backends: Vec<Backend>, options: ResolverOptions) -> io::Result<Self>;}| 解析器 | hosts |
dialer |
bootstrap |
socket |
|---|---|---|---|---|
Single(System) |
无(getaddrinfo 自己读取该文件) |
无 | 无 | 默认值,不使用 |
Single(other) |
无 | 无 | 无 | supervisor 的 |
Split direct |
开启 use_hosts 时为该表 |
无 | 无 | supervisor 的 |
Split proxied |
开启 use_hosts 时为同一张表 |
RoutedDialer |
direct |
supervisor 的,但不使用:每个连接都经过拨号器,而拨号器选中的出站在同一策略下拨号 |
服务及其目标
Section titled “服务及其目标”#[derive(Clone, Debug)]pub struct Service { /* resolver: Resolver, ipv6: bool */ }
impl Service { pub fn new(resolver: Resolver, ipv6: bool) -> Self; pub async fn answer(&self, query: &[u8], transport: Transport) -> Option<Vec<u8>>;}
pub enum Transport { Udp, Tcp,}对不是查询的字节,answer 返回 None,调用方随即什么也不发送。否则它返回一个响应,由私有函数 respond 按以下顺序决定:
| 问题 | 响应 |
|---|---|
| 无法解码为查询的字节 | None,并在 debug 级别记录 dns service: dropping an undecodable query: <error> |
| opcode 不是 0(标准查询) | NOTIMP,不带记录 |
类别 IN 的 AAAA,且服务构建时 resolve_ipv6 为 false |
空的 NOERROR,不询问任何人 |
类别 IN 的 A 或 AAAA,名称在解析器的 hosts 表中 |
带表中地址的 NOERROR,TTL 为 SYSTEM_TTL |
| 其他任何问题 | Resolver::relay:查询按原样依次发给每台服务器,返回第一个 id 匹配的响应,不做任何改动 |
relay 以 Unsupported 失败(dns: no server that a query can be relayed to) |
A 或 AAAA:用解析器自己的查询(resolve_with_ttl)给出应答,即带地址的 NOERROR;查询报告名称不存在时为 NXDOMAIN;其他情况为 SERVFAIL。其他任何问题:空的 NOERROR。 |
relay 以其他方式失败 |
SERVFAIL,并在 debug 级别记录 dns service: <name> failed: <error> |
服务自己构建的记录,TTL 以整秒计,最小为 1(ttl_secs)。relay 会跳过 system 服务器,因为 getaddrinfo 接受的是名称而不是报文,所以 Unsupported 表示解析器没有能接受报文的服务器。在 supervisor 中,这只会发生在 through_proxy 为空的 Split 下,且 pre_proxy 为空或只包含 system。
在 Transport::Udp 上,超过查询上限的响应会被替换为一个设置了截断位的空响应。上限是客户端通告的 EDNS 载荷大小,但不小于传统的 512 字节(Query::udp_limit)。客户端随后通过 TCP 重新询问,TCP 上任何大小都能容纳。
supervisor 把服务包装成一个它自己的出站:
pub enum Outbound { // ... one variant per protocol ... /// The supervisor's DNS service, answering applications' queries. Dns(etemenanki_protocols::dns::serve::Service),}| 调用 | Outbound::Dns 返回 |
|---|---|
connect_stream(flow) |
一个已就绪的 future,值为 OutboundStream::Proxy(Box::pin(DnsTcpStream::new(service))) |
connect_datagram(flow) |
一个已就绪的 future,值为 OutboundDatagram::new(DnsUdpLink::new(service)) |
两个调用都不看流的目的地。无论应用程序把查询发往哪个地址,都由服务应答。
Actor::prepare 把 Dns::service 变成一个 plane 目标:Target::outbound(internal_id(epoch + 1), Outbound::Dns(service))。internal_id 给它一个空 tag,版本等于首次承载它的 plane 的 epoch,因此至少为 1。校验会拒绝 spec 中的空 tag(supervisor/src/build/validate.rs → unique_tags,报 a <kind> tag must not be empty),它的文档注释说明了原因:空 tag 归 supervisor 自己所有,用于它自己创建的目标。所以这个 id 永远不会指向 spec 中的出站。另一个带空 tag 的目标只有 Plane::empty 的 blackhole,版本为 0。OutboundId 显示为 <tag>@v<version>(supervisor/src/entity/id.rs),所以这个 id 打印为 @v<version>。在它上面打开的流都归在这个 id 下,见流跟踪、统计与限速。
plane 中的拦截
Section titled “plane 中的拦截”pub const DNS_PORT: u16 = 53;
pub struct Plane { routes: Arc<CompiledRoutes>, targets: Box<[Arc<Target>]>, /// The service applications' DNS is answered by, when the supervisor /// answers it itself. dns: Option<Arc<Target>>, epoch: u64,}
impl Plane { pub fn route(&self, flow: &Flow, ctx: &FlowContext) -> Routed<'_>; pub fn route_upstream(&self, flow: &Flow, ctx: &FlowContext) -> Routed<'_>;}
pub struct Routed<'a> { pub target: &'a Arc<Target>, pub rule: Option<RuleId>,}- 每个应用程序流都用
route选路。supervisor/src/connector.rs→AppConnector::connect对每个 TCP 流调用它,FanOutLink::poll_send_to对每个 UDP 包调用它。当dns已设置且flow.destination.port == DNS_PORT时,它不查路由表,直接返回 DNS 目标,rule: None。否则交给route_upstream。 route_upstream只询问路由表,返回槽位的目标以及匹配到的规则。RoutedDialer使用它,所以服务自己的查询永远不会被送回服务。
拦截只看端口,TCP 和 UDP 都一样。route 的注释给出了原因:UDP 应答被截断时,应用程序会回退到 TCP,而这次重试必须到达同一个服务。目的地址、入站和嗅探到的名称都不起作用。首次应用之前存在的 plane(Plane::empty)没有 DNS 目标,只有一个 blackhole 槽位,所以 53 端口和其他一切一起被丢弃。
DnsUdpLink
Section titled “DnsUdpLink”const MAX_PENDING: usize = 64;
type AnswerFuture = Pin<Box<dyn Future<Output = Option<Vec<u8>>> + Send>>;
fn answer(service: &Service, query: Vec<u8>, transport: Transport) -> AnswerFuture;
pub struct DnsUdpLink { service: Service, /// Each query in flight, with the address it was sent to: the answer /// comes back from there. pending: Vec<(Destination, AnswerFuture)>, recv_waker: Option<Waker>,}
impl DnsUdpLink { pub fn new(service: Service) -> Self;}
impl DatagramLink for DnsUdpLink { type Addr = Destination; // poll_send_to, poll_recv_from}模块 topology::outbound::dns 是公开的,所以 DnsUdpLink、DnsTcpStream、RoutedDialer 及其 new 构造函数都是 etemenanki-supervisor 的公开 API(etemenanki_supervisor::topology::outbound::dns)。私有辅助函数 answer 由两种 link 共用:它把 Service 克隆进一个 boxed future,这个 future 持有查询,并 await Service::answer(&query, transport)。
DnsUdpLink 是 UDP 关联的 fan-out(supervisor/src/topology/outbound/udp_fanout.rs → FanOutLink)中的一个子 link。fan-out 按目标 id 索引子 link,所以当关联向 53 端口发送、而当前 DNS 目标上还没有它的子 link 时,它就打开一个 DnsUdpLink:可能是该关联的第一个 53 端口包,可能是 DNS 变更给了目标新的 id 之后,也可能是 fan-out 丢弃了之前那个子 link 之后。fan-out 最多保留 MAX_SUBS(64)个子 link,超出时丢弃最久未被发送过数据的那个;被丢弃的 DnsUdpLink 会带走其上所有待处理的应答。失败的子 link 也会被丢弃。子 link 表见出站、UDP fan-out 与负载均衡器。每个数据报是一个查询。
poll_send_to(buf, to)把buf复制到一个Vec中,并启动answer(query, Transport::Udp)。它把这个 future 和to一起推入pending,并唤醒挂起的接收方。如果这个 link 上已有MAX_PENDING(64)个查询在处理中,该数据报会被丢弃,并在 debug 级别记录dns service: 64 queries in flight, dropping one。代码注释解释了原因:超过这个上限的客户端是在泛洪,而繁忙的服务器同样会丢弃多出来的数据报。两种情况下调用都返回Poll::Ready(Ok(buf.len())),所以发送方看到的是一次成功的发送,就像在会丢包的网络上一样。它从不失败。poll_recv_from(buf)依次 poll 每个待处理的 future。第一个完成的用swap_remove取出。None应答(该数据报不是查询)被丢弃,扫描继续。Some(response)被复制到buf,并连同查询发往的Destination一起返回。比读取方缓冲区大的响应会被截断到能放下为止(response.len().min(buf.remaining())),与 socket 截断数据报的方式相同。应用程序会检查回复的来源是否是它询问的服务器,所以应答必须来自那个地址。没有就绪的结果时,任务的 waker 存入recv_waker,调用返回Pending。
应答 future 只在 poll_recv_from 中被 poll。只有当 link 的持有者从它接收时,它们才会推进,它们没有自己的任务。swap_remove 不保持顺序,所以应答返回的顺序可能与查询不同,这正是 UDP 允许的。
DnsTcpStream
Section titled “DnsTcpStream”const MAX_MESSAGE: usize = u16::MAX as usize;
pub struct DnsTcpStream { service: Service, /// Written by the client, not yet taken as a query. inbound: Vec<u8>, /// Framed answers not yet read, from `outbound_at` on. outbound: Vec<u8>, outbound_at: usize, answering: Option<AnswerFuture>, /// The client shut down its sending side: once what it sent is answered, /// reads end. write_closed: bool, read_waker: Option<Waker>, write_waker: Option<Waker>,}
impl DnsTcpStream { pub fn new(service: Service) -> Self; fn take_query(&mut self) -> Option<Vec<u8>>;}
impl AsyncRead for DnsTcpStream { /* poll_read */ }impl AsyncWrite for DnsTcpStream { /* poll_write, poll_flush, poll_shutdown */ }一个 DnsTcpStream 对应应用程序到 53 端口的一条 TCP 连接。它使用 DNS over TCP 的分帧方式(RFC 1035 §4.2.2):每个报文前面是 2 字节大端序长度。中继这条连接的运行时把客户端的字节写入它,再把应答读回来。
写入。 poll_write:
- 客户端关闭发送方向后,返回
BrokenPipe; - 计算
room = (MAX_MESSAGE + 2).saturating_sub(inbound.len()),即一整个报文加上长度前缀的空间(65,537 字节); - 没有空间时,存下
write_waker并返回Pending,这样写得比应答被读走更快的客户端就要等待; - 否则追加最多
room个字节,唤醒挂起的读取方,并返回接收了多少字节。
poll_flush 什么也不做。poll_shutdown 设置 write_closed 并唤醒挂起的读取方。
读取。 poll_read 循环执行以下四步,每轮采用第一个适用的步骤:
- 未读的应答字节。 把能放下的部分复制到调用方的缓冲区并返回。缓冲区被读到末尾后清空。
- 处理中的应答。 poll 它,未完成则返回
Pending。完成时清空answering。Some(response)连同它的 2 字节大端序长度一起追加到outbound。None(这些字节不是查询)不产生任何帧,与 UDP 上相同。 inbound中有完整的查询。 在inbound同时有两个长度字节和其后的完整报文之前,take_query都返回None。凑齐之后,它从inbound中移除前缀和报文,唤醒挂起的写入方,并返回该报文。循环随即启动answer(query, Transport::Tcp)。- 无事可做。 如果客户端已关闭发送方向,返回
Ready且什么也没读到,即流结束。否则存下read_waker并返回Pending。
由这个顺序可以得出:
- 应答按顺序逐个给出。 同一时刻只存在一个应答 future,并且只有在上一个应答被完整读出之后才会取下一个查询。客户端可以流水线式地发送查询,但它们按写入顺序应答。
- 流只在最后一个应答之后结束。
shutdown之后,读取会继续,直到每个完整的查询都已应答并被读出。客户端只写了一部分的查询被丢弃,然后流结束。 - 缓冲区有上限。
inbound永远不超过 65,537 字节,outbound最多持有一个带帧的应答。 - 只有流被读取时应答才会推进。 应答 future 只在
poll_read中被 poll,别处不会。
RoutedDialer
Section titled “RoutedDialer”const DNS_FLOW_TAG: &str = "dns";
pub struct RoutedDialer { plane: Weak<ArcSwap<Plane>>,}
impl RoutedDialer { pub fn new(plane: Weak<ArcSwap<Plane>>) -> Self;}
impl StreamDialer for RoutedDialer { fn dial(&self, server: SocketAddr) -> DialFuture;}经代理的解析器每次需要连接到它的某台服务器时,都会调用 dial(server)。它分两个阶段工作。
路由在调用 dial 时同步选定:
- 它构建
Flow::new(Destination { network: DialNetwork::Tcp, remote: Remote::IpAddr(server.ip()), port: server.port() }, anonymous_user(), None)。这个流没有来源,它的用户是supervisor/src/topology/flow.rs→anonymous_user,即基于Principal::anonymous()的空用户名和空密码。 - 它构建
FlowContext { inbound_tag: "dns", source: None },以DNS_FLOW_TAG作为入站 tag。 - 它升级弱引用,加载当前 plane,取
route_upstream(&flow, &ctx).target。Routed同时携带的匹配规则被丢弃。
连接在返回的 future 中打开:
- 如果升级失败,future 以
NotConnected失败:dns: the supervisor this resolver dials through is gone。 - 否则它调用
target.connect_stream(flow)。负载均衡器在那一刻解析为它的某个成员。如果那个成员本身也是负载均衡器,connect_stream会以balancer member <id> is itself a balancer失败;校验会拒绝这样的 spec。得到的Guarded<OutboundStream>被装箱为BoxedDnsStream返回。
这对解析器的流量意味着:
- 每次交换都遵循发送时的当前路由表。 解析器与服务器的每次交换都会打开一个新连接,而
dial每次都加载 plane,所以路由变更会作用于下一次交换。一次名称查询会并发询问A和AAAA,所以它每尝试一台服务器就调用两次dial。一个被中继的应用程序查询每尝试一台服务器调用一次。 - 规则看到的是一个发往 IP 地址的 TCP 流。 流的端口就是服务器的端口:普通 DNS 默认 53(这里通过 TCP 询问),DNS over TLS 为 853,DNS over HTTPS 为 443。它的网络是 TCP,入站 tag 是
dns,没有来源,也没有嗅探到的名称。CIDR、GeoIP、端口、网络和inbound_tag规则可以匹配它。域名规则不能,因为目的地是地址。校验不保留dns这个 tag:tag 为dns的入站,会被匹配解析器流的同一批inbound_tag规则匹配。 - 这些 stream 不计量。
RoutedDialer直接打开目标,不经过AppConnector。这些流不属于任何会话,从不在 tracker 中登记,没有记录其规则或 epoch 的FlowMeta,也不计入任何用户的用量。它们确实带有Guarded包装,所以关闭它们所在出站版本的排空会以the outbound this flow was opened on was drained结束它们。 - TLS 是端到端的。 对于 DNS over TLS 和 DNS over HTTPS,解析器在路由得到的 stream 上运行自己的 TLS 客户端。出站只承载加密后的字节,永远看不到查询内容。
对 plane 的引用是弱引用,原因是存在一个环:plane cell 持有当前 plane,plane 持有它的目标,目标持有用 proxied 构建的出站和 DNS 服务,而 proxied 持有这个拨号器。强引用会让 cell 及其当前 plane 永远存活。只有在 cell 的每个强引用持有者都被 drop 之后,升级才会失败,这些持有者包括 actor 的共享状态、每个 Supervisor handle、各个 connector(连接器)、fan-out link,以及 TUN 入站的任务。
哪个使用方用哪个解析器
Section titled “哪个使用方用哪个解析器”supervisor/src/build/outbound.rs → build_outbound(spec, &dns, socket) 按每个出站要查询的内容,把相应的解析器交给它。Actor::commit 把 dns.servers 交给每个新负载均衡器的健康探测。
| 使用方 | 从哪里拿到解析器 | 解析器 | 查询什么 |
|---|---|---|---|
| SOCKS、HTTP、Trojan、VLESS、VMess、Shadowsocks 和 Shadowsocks 2022 出站的传输层 | build/outbound.rs → transport → TransportConnector::new(kind, dialer, dns.servers, address_family) |
servers |
代理服务器的主机名 |
| Hysteria 2 出站 | Hy2Connector::with_address_family(...).with_resolver(servers) |
servers |
Hysteria 2 服务器的主机名 |
| WireGuard endpoint | WgConfig::endpoint_resolution = Some((servers, endpoint_address_family)) |
servers |
对端 endpoint 的主机名 |
| WireGuard 隧道 | WgConnector::with_address_family(...).with_resolver(dns.destinations) |
destinations |
在隧道内访问的名称 |
| Freedom 出站,TCP 和 UDP | FreedomConnector::new(Dialer::new(socket), dns.destinations, address_family) |
destinations |
目的地名称 |
| 负载均衡器健康探测 | Actor::commit 中的 Balancer::spawn_probe(..., dns.servers, TcpDialer::new(socket)) |
servers |
每个成员的上游服务器,使用 AddressFamilyStrategy::Auto |
| DNS 服务 | Service::new(proxied, resolve_ipv6) |
destinations(proxied) |
应用程序的查询 |
| Blackhole 出站 | 无 | 无 | 不查询 |
在 Single 下,每一行拿到的都是同一个解析器。在 Split 下,这张表把查询分为两组:
- 到达代理所需的一切使用
servers,从本机询问。代理服务器的名称必须在代理能承载任何流量之前就已知,所以经由代理来解析它会形成循环。 - 所有作为目的地的名称使用
destinations;当through_proxy指定了服务器时,它的查询经由路由表发出。这包括 freedom 拨号的目的地、WireGuard 隧道内的名称,以及应用程序询问的每个名称。
SOCKS 出站的 UDP 中继发往服务器 UDP ASSOCIATE 回复中给出的地址,所以它不解析任何名称。
构建 Split 配置
Section titled “构建 Split 配置”flowchart LR spec["DnsSpec::Split"] hosts["Hosts::system,开启 use_hosts 时"] direct["direct 解析器:pre_proxy"] proxied["proxied 解析器:through_proxy"] dialer["RoutedDialer,plane cell 弱引用"] service["Service,resolve_ipv6"] servers["Dns.servers"] dests["Dns.destinations"] target["Plane.dns 中的 Outbound::Dns 目标"] spec --> direct spec --> proxied hosts --> direct hosts --> proxied direct -->|"bootstrap"| proxied dialer -->|"dialer"| proxied direct --> servers proxied --> dests proxied --> service service --> target
路由:拦截与上游
Section titled “路由:拦截与上游”flowchart TB
app["应用程序的流或 UDP 包"] --> route["Plane::route"]
route --> check{"Plane.dns 已设置且端口为 53?"}
check -->|"是"| dns["DNS 目标:DnsTcpStream 或 DnsUdpLink"]
check -->|"否"| table["路由表"]
own["RoutedDialer 发起的解析器连接"] --> upstream["Plane::route_upstream"]
upstream --> table
table --> out["出站或负载均衡器"]
一次应用程序查询
Section titled “一次应用程序查询”下面的时序图展示了在指定了 through_proxy 服务器的 Split 配置下的一次 UDP 查询。TCP 查询走同样的路径,只是经过 AppConnector::connect 和 DnsTcpStream,而不是 fan-out 和 DnsUdpLink。
sequenceDiagram participant App as 应用程序 participant RT as 入站运行时 participant F as FanOutLink participant L as DnsUdpLink participant S as Service participant R as proxied Resolver participant D as RoutedDialer participant O as 路由选中的出站 App->>RT: 经 UDP 发往 53 端口的查询 RT->>F: poll_send_to(query, to) F->>F: Plane::route 选中 DNS 目标 F->>L: poll_send_to,处理中的查询少于 64 个时入队 RT->>F: poll_recv_from F->>L: poll_recv_from 逐个 poll 应答 future L->>S: answer(query, Transport::Udp) S->>R: relay(query) R->>D: dial(server) D->>D: Plane::route_upstream,入站 tag 为 dns D->>O: connect_stream(flow) O-->>R: 到服务器的 stream R-->>S: id 匹配的响应 S-->>L: 响应,过大时为截断的响应 L-->>F: 来自所询问地址的应答 F-->>RT: 数据报 RT-->>App: 应答
在到达 plane 之前,DNS 流就是一个普通的流。如果 connector 有会话,它就在该会话上准入这个流;TUN 入站的流不属于任何会话。对 UDP 而言,这次准入只在打开关联的 fan-out 时发生一次。TCP stream 或 fan-out 的子 link 作为一个流计量,归在 DNS 目标的 id 下(见流跟踪、统计与限速)。同一关联的每个 53 端口包都被路由到同一个目标,所以无论应用程序询问的是哪个服务器地址,它们都共用该关联在这个目标上的 DnsUdpLink。
有些查询不经过路由表就结束:
- 服务自己应答的查询(非标准 opcode、
resolve_ipv6关闭时的AAAA,或 hosts 条目)永远不会到达relay。 through_proxy为空。 这时proxied就是direct,所以每个被中继的查询都从本机直接询问,受 socket 策略约束,也不存在RoutedDialer。- 没有可中继的服务器。
through_proxy为空,且pre_proxy列表为空或只包含system时,relay以Unsupported失败。服务用其解析器自己的查询应答A和AAAA(对system用getaddrinfo,不受 socket 策略约束),对其他问题应答空的NOERROR。 Single。 没有服务。53 端口的流于是和其他流一样由路由表路由,应用程序经由规则选中的出站与它自己的 DNS 服务器通信。
多次应用之间
Section titled “多次应用之间”supervisor/src/topology/spec_plan/plan.rs → plan 用 == 比较 DnsSpec 值:
- 首次应用,或
DnsSpec发生变化。 计划以Step::Build(Resource::Dns)开头,并把 plane 标记为已变化。随后除 blackhole 外的每个出站都会重建为新版本,即使它自己的 spec 没有变化,因为其他每种出站都持有一个即将被替换的解析器。计划为每个旧版本加入一个Step::Drain(见规划与应用变更)。只要负载均衡器的某个成员被重建,负载均衡器就会重建。校验只允许具有 TCP 可达上游的出站作为成员(build/validate.rs→probe_target),这排除了 blackhole、freedom、WireGuard 和 Hysteria 2 出站,所以 DNS 变更也会重建每个负载均衡器。计划发布 plane(Step::PublishPlane),旧版本的Drain步骤跟在它后面,位于计划末尾。 DnsSpec未变。 计划包含Step::Reuse(Resource::Dns),出站只根据它们自己的 spec 规划。
DnsSpec 中的任何差异都算作变化,包括服务器列表的顺序、resolve_ipv6 或 use_hosts 的切换,以及不同的固定 CA(Backend 比较 CA 的字节)。
let rebuild_dns = plan.steps.contains(&Step::Build(Resource::Dns));let (dns, dns_target) = match (&self.dns, rebuild_dns) { (Some(dns), false) => (dns.clone(), self.dns_target.clone()), _ => { let dns = build_dns(&spec.dns, Arc::downgrade(&self.shared.plane), &self.socket) .map_err(build(Resource::Dns))?; let target = dns.service.clone().map(|service| { Arc::new(Target::outbound(internal_id(self.epoch + 1), Outbound::Dns(service))) }); (dns, target) }};名称解析在准备(prepare)和提交(commit)两个阶段中经历的事:
| 阶段 | 名称解析发生了什么 |
|---|---|
prepare,最先 |
DNS 配置先于其他一切确定,因为每个出站都要用 &dns 构建。复用的配置是同一个 Dns 值:相同的解析器、已预热的缓存,以及同一个 DNS 目标 Arc。重建的配置则是新的解析器(缓存为空)和新的目标。构建错误会在准备任何出站、路由表、handler(处理器)或监听器之前返回 ApplyError::Build { resource: Resource::Dns, source }。 |
prepare,出站 |
对每个 Step::Build(Resource::Outbound(_)) 调用 build_outbound(outbound, &dns, &self.socket) |
commit,执行 PublishPlane 时 |
Plane::new(routes, slots, dns_target.clone(), epoch) 通过一次原子 store 存入 plane cell。路由、出站和 DNS 目标一起变化。计划没有复用的每个负载均衡器,其探测 token 被取消;新构建的负载均衡器用 dns.servers 启动探测。 |
commit,之后 |
self.dns = Some(dns) 和 self.dns_target = dns_target |
Actor 用两个字段保存正在运行的配置:dns: Option<Dns>,以及 dns_target: Option<Arc<Target>>,即到达 DNS 服务所经由的内部目标。Prepared 把两者从 prepare 带到 commit。
由于复用的配置保留其目标 Arc,在不改动 DnsSpec 的应用(例如路由变更)前后,DNS 目标的 id 保持不变。fan-out 以这个 id 为键的 UDP 关联 DNS 子 link 于是继续使用。
一次应用的 ApplyReport 把 Resource::Dns(显示为 dns)列在 built 或 reused 下。etemenanki-app 在它的 config loaded: 和 config reloaded: info 日志行中打印这份报告(app/src/instance.rs → summary),所以配置被重建时 dns 出现在 built [...] 列表中,被保留时出现在 reused [...] 列表中。
supervisor/src/supervisor.rs → check(重新导出为 etemenanki_supervisor::check)是 etemenanki-app 和 katana 在 --test 时调用的函数。它从空状态开始规划,并在一个全新的 actor 上以 SocketOptions::default() 运行 prepare,不做任何绑定。所以它同样会构建 DNS 配置,在设置了 use_hosts 时读取 hosts 文件,并拒绝启动时会拒绝的内容。
DNS 目标上已打开的流
Section titled “DNS 目标上已打开的流”DNS 目标不在 actor 的 targets 中,而 Step::Drain 正是在这个 map 里查找版本的。没有计划会为它安排排空,所以任何排空策略和 close_flows 都不作用于它。DNS 变更之后:
- TCP。 已打开的 53 端口连接保留它的
DnsTcpStream,也就保留了旧的Service:旧的服务器、hosts 表和resolve_ipv6,直到连接结束。它中继的查询仍然遵循当前路由表,因为RoutedDialer每次拨号都会加载 plane cell。 - UDP。 关联的下一个 53 端口包被路由到新目标。新目标的 id 不同,所以 fan-out 在新服务上打开一个
DnsUdpLink。旧子 link 上已在等待的应答仍会从旧子 link 返回。 - 新流只会到达新目标。
本页的组件都不 spawn 任务:
DnsUdpLink和DnsTcpStream的应答 future 在 poll 该 link 的任务中运行,通常是连接的运行时。drop 这个 link 就会 drop 它们,连同正在进行的交换。- 解析器是引用计数的。只要还有人持有它的克隆,它就存活:actor 的
Dns、用它构建的出站和健康探测,以及 DNS 目标和在其上打开的 link。 - 解析器的连接(无论是否经过路由)归打开它们的查询 future 所有,并在该 future 结束时关闭。
- 负载均衡器的健康探测用
servers解析,作为任务运行在 supervisor 的 task tracker 上,每个都在根 token 的一个子 token 之下。当计划不复用该负载均衡器(因为它被重建或删除)时,commit会取消这个 token。
DnsSpec 从哪里来
Section titled “DnsSpec 从哪里来”supervisor 没有配置格式。前端程序把各自的格式降为 DnsSpec:
| 前端程序 | lowering | 产出 |
|---|---|---|
etemenanki-app,[dns] |
app/src/lower.rs → lower_dns → lower_single_dns → Backend::from_spec |
Single(backend)。没有 [dns] 时为 Single(Backend::System)。 |
etemenanki-app,使用含 [dns] 的订阅文件 |
app/src/lower.rs → lower_dns,每个 URL 经过 Backend::from_url |
Split,pre_proxy、through_proxy、use_hosts 和 resolve_ipv6 从文件中照搬 |
katana,[dns] |
katana src/lower/outbound.rs → lower_dns,由 src/lower/mod.rs → shared_spec 调用 |
每个节点 spec 中的 Single(backend) |
app 的 lowering 见etemenanki-app:从 TOML 到 spec和订阅文件。katana 的 lowering 见 katana 的页面。 这里要注意的几点:
lower_single_dns把ca_file读入后端的ca_pem。app 通过read_file读取,错误信息中会写出键名。katana 的lower_dns用std::fs::read读取,原样传出操作系统错误。- 订阅文件中的服务器是 URL,
Backend::from_url为每一个都设置ca_pem: None。因此固定的 CA 只能来自配置的[dns],而它降为Single。 - 订阅文件有
[dns]段时,配置自己的[dns]被忽略,也不会被降为 spec。如果配置的[dns]与DnsConfig::default()不同,app/src/subscribe.rs→apply会记录一条警告(target 为etemenanki_app::subscribe),以[subscribe].path的原始写法指出订阅文件,例如sub.toml has a [dns] section, so the config's [dns] is ignored。 - 订阅文件的
[dns]是 app 产生Split的唯一途径,所以只有在使用这样的文件时,app 才会自己应答应用程序的 DNS。 - katana 每个节点运行一个 supervisor,所以每个节点构建自己的解析器,拥有自己的缓存。katana 从不产生
Split,所以它的节点从不拦截 53 端口。见 katana 内部结构。
订阅文件的 [dns] 表是 subscribe/src/dns.rs → DnsConfig。它标注了 deny_unknown_fields,所有键都没有默认值:写了 [dns] 的文件必须写全五个键,漏写或多写一个键都会解析失败。
| 键 | 类型 | 降为 |
|---|---|---|
pre_proxy |
URL 字符串数组,可以为空 | Split::pre_proxy |
through_proxy |
URL 字符串数组,可以为空 | Split::through_proxy |
use_hosts |
bool | Split::use_hosts |
connect_ipv6 |
ProxyServerIpv6DnsPolicy:required、preferred、tolerated 或 forbidden |
不属于 DnsSpec。app/src/subscribe.rs → address_family 把它映射为 ipv6_only、prefer_ipv6、prefer_ipv4 或 ipv4_only,作为每个节点的 address_family;对 WireGuard 节点则作为 endpoint_address_family。 |
resolve_ipv6 |
bool | Split::resolve_ipv6 |
一个订阅文件的 [dns] 及其对应的 spec,地址均为占位值:
[dns]pre_proxy = ["udp://192.0.2.53:53"]through_proxy = ["https://198.51.100.53/dns-query", "tls://203.0.113.53:853#dns.example.com"]use_hosts = trueconnect_ipv6 = "preferred"resolve_ipv6 = trueDnsSpec::Split { pre_proxy: vec![Backend::Udp(ServerAddr::Ip("192.0.2.53:53".parse()?))], through_proxy: vec![ Backend::Https { server: ServerAddr::Ip("198.51.100.53:443".parse()?), host: "198.51.100.53".into(), path: "/dns-query".into(), ca_pem: None }, Backend::Tls { server: ServerAddr::Ip("203.0.113.53:853".parse()?), server_name: "dns.example.com".into(), ca_pem: None }, ], use_hosts: true, resolve_ipv6: true,}| 不变量 | 机制 | 对应测试 |
|---|---|---|
当且仅当 spec 为 Split 时,应用程序发往 53 端口的流通过 TCP 和 UDP 都会到达服务 |
Plane::route 检查 self.dns 和 DNS_PORT;Plane.dns 恰在 Dns::service 为 Some 时为 Some |
supervisor/tests/unit/plane.rs → port_53_is_intercepted_except_for_the_services_own_queries、without_a_dns_service_port_53_follows_the_route_table |
| 服务自己的查询永远不会回到服务 | RoutedDialer 用 route_upstream 选路,而它从不返回 DNS 目标 |
port_53_is_intercepted_except_for_the_services_own_queries |
| 首次应用之前,53 端口和其他一切一样被丢弃 | Plane::empty 没有 DNS 目标,只有一个 blackhole 槽位 |
plane.rs → the_empty_plane_drops_everything |
| DNS 变更会重建解析器和除 blackhole 外的每个出站,排空旧版本,并发布 plane | plan:dns_changed 强制对每个 uses_dns 的出站执行 Step::Build |
supervisor/tests/unit/plan.rs → a_dns_change_rebuilds_every_outbound_but_a_blackhole |
不改动 DnsSpec 的应用会保留解析器、它们的缓存和 DNS 目标 |
Step::Reuse(Resource::Dns);prepare 克隆 self.dns 和 self.dns_target |
只覆盖规划这一半:plan.rs → a_route_change_alone_rebuilds_only_the_route_table 检查 Reuse(Resource::Dns)。没有测试检查 prepare 随后是否保留同样的解析器和目标。 |
| 首次计划在任何出站之前构建 DNS | plan 最先推入 DNS 步骤 |
plan.rs → the_first_plan_binds_and_builds_everything_then_publishes |
| 在 TCP 上,流水线式的查询在同一个 stream 上按顺序应答;客户端关闭发送方向并得到应答后,流结束 | 只有一个 answering future;outbound 清空后才调用 take_query;只有 write_closed 且没有剩余内容时才结束流 |
supervisor/tests/unit/dns_outbound.rs → tcp_answers_pipelined_queries_in_order_then_ends |
| 在 UDP 上,应答来自查询发往的地址 | pending 在每个 future 旁边保存 to |
dns_outbound.rs → udp_answers_from_the_address_asked |
| 解析器永远无法使用的服务器在构建 spec 时就被拒绝,而不是等到查询失败 | with_upstreams 拒绝没有 bootstrap 的命名服务器,以及有拨号器时的 system |
protocols/tests/unit/dns/mod.rs → a_named_server_without_a_bootstrap_is_refused(对 system 的拒绝没有测试) |
--test 会构建订阅文件描述的 DNS 配置 |
check 运行 prepare,后者调用 build_dns |
app/tests/unit/subscribe.rs → every_lowered_node_builds_and_the_dns_setup_with_it |
app 把 [dns] 降为 Single,把订阅文件的 [dns] 降为 Split |
app/src/lower.rs → lower_dns |
app/tests/unit/lower.rs → a_server_config_lowers_to_its_spec、a_dns_backend_needs_what_it_names、a_subscribe_file_splits_dns |
| 配置的后端确实在应答,缺少服务器时会被拒绝 | Backend::from_spec 和 Single 分支 |
app/tests/integration/e2e_dns.rs → the_udp_backend_is_used_for_resolution、the_default_backend_does_not_know_the_test_name、a_backend_without_its_server_is_rejected |
| 从本机发出的查询使用 supervisor 的 socket 策略 | 对每个自己打开 socket 的解析器,ResolverOptions::socket 都是 supervisor 的 socket |
protocols/tests/unit/dns/mod.rs → a_query_socket_gets_the_socket_policy(在解析器层面) |
失败路径与取消
Section titled “失败路径与取消”以下每一种情况都会让 build_dns 失败,于是 prepare 返回 ApplyError::Build { resource: Resource::Dns, source }(supervisor/src/build/apply.rs),显示为 building dns failed: <source>。这次应用的任何部分都不会生效,正在运行的 supervisor 保留原有的一切。
| 条件 | source |
|---|---|
开启 use_hosts 的 Split,hosts 文件存在但无法读取 |
操作系统错误,例如 Permission denied (os error 13) |
through_proxy 中有 system |
dns: the system resolver cannot be reached through a proxy |
pre_proxy 中或 Single 中以名称写出的服务器 |
dns: server "<host>" is a name, and nothing resolves it; write its address |
| 固定的 CA 中不含任何证书的 DNS over TLS 或 DNS over HTTPS 服务器 | no certificate in CA PEM bundle |
| 固定的 CA 中有无法解析的证书块 | X509::stack_from_pem 返回的 OpenSSL 错误(protocols/src/transports/tls/config.rs) |
有些错误永远到不了 build_dns,因为前端程序会先拒绝它们。对这些错误,核对版本的 etemenanki-app 在 --test 时打印:
| 错误 | 由谁拒绝 | 输出行 |
|---|---|---|
[dns] 中无法读取的 ca_file |
app/src/lower.rs → read_file |
configuration invalid: dns: cannot read ca_file "/etc/etemenanki/missing-ca.pem": No such file or directory (os error 2) |
[dns] 的 server 写成名称 |
Backend::from_spec |
configuration invalid: dns: invalid server address: invalid socket address syntax |
[dns] 设置了 backend = "udp" 但没有 server |
Backend::from_spec |
configuration invalid: dns: the udp backend needs a server address |
| 订阅文件中 scheme 未知的服务器 URL | Backend::from_url |
configuration invalid: dns: server "ftp://192.0.2.53": unknown scheme "ftp" |
订阅文件的 [dns] 缺少 use_hosts |
订阅文件解析器 | 第一行:configuration invalid: subscribe: TOML parse error at line 1, column 1 |
build_dns 失败时 etemenanki-app 打印的内容如下。--test 的输出行用核对版本的二进制实际捕获;启动和重载的输出行是 app/src/main.rs 和 app/src/instance.rs 中的格式。
| 时机 | 输出行(除注明外,target 为 etemenanki_app) |
|---|---|
--test |
configuration invalid: building dns failed: dns: the system resolver cannot be reached through a proxy |
--test |
configuration invalid: building dns failed: dns: server "dns.example.com" is a name, and nothing resolves it; write its address |
--test |
configuration invalid: building dns failed: no certificate in CA PEM bundle |
| 启动 | failed to start: building dns failed: <source> |
重载(target 为 etemenanki_app::instance) |
reload: building dns failed: <source>; keeping the running config |
| 位置 | 条件 | 结果 |
|---|---|---|
DnsUdpLink::poll_send_to |
该 link 上有 64 个查询在处理中 | 数据报被丢弃,发送报告成功,并记录一条 debug 日志 |
FanOutLink |
fan-out 为保持在 MAX_SUBS 以内而丢弃一个 DnsUdpLink |
其上待处理的应答随之丢弃 |
Service::answer |
字节不是 DNS 查询 | None:UDP 上不发数据报,TCP 上不产生帧。发往 53 端口的 TCP 连接上,无法解码为查询的字节得不到应答。 |
Service::answer |
中继失败 | SERVFAIL。没有可中继的服务器时,改由服务自己的查询决定(见服务及其目标)。 |
DnsTcpStream::poll_write |
客户端已关闭发送方向 | BrokenPipe |
DnsTcpStream |
客户端在一个查询中途关闭 | 不完整的查询被丢弃,流结束 |
RoutedDialer::dial |
plane cell 已不存在 | NotConnected,dns: the supervisor this resolver dials through is gone。解析器尝试下一台服务器。 |
RoutedDialer stream |
路由选中 blackhole | stream 立即读到流结束。这次交换失败,解析器尝试下一台服务器。 |
RoutedDialer stream |
它所在的出站版本被排空关闭 | ConnectionAborted,the outbound this flow was opened on was drained。这次交换失败。 |
RoutedDialer stream |
路由选中的出站无法到达服务器 | 该出站的拨号错误。这次交换失败。 |
Resolver::relay |
某台服务器的响应带有不同的 id | dns: relayed response does not match the query id。解析器尝试下一台服务器。 |
失败的交换永远不会直接传到应用程序。解析器转向下一台服务器;没有任何服务器应答时,Service 把最后一个错误转换成应答码。
以下各行都在 debug 级别:
| target | 输出行 |
|---|---|
etemenanki_supervisor::topology::outbound::dns |
dns service: 64 queries in flight, dropping one |
etemenanki_protocols::dns::serve |
dns service: dropping an undecodable query: <error> |
etemenanki_protocols::dns::serve |
dns service: <name> failed: <error> |
build_dns 和服务出站都不记录高于 debug 级别的日志。构建错误以 ApplyError 值的形式到达前端程序,由前端程序记录。
- 连接结束时,会 drop 它的
DnsTcpStream或 fan-out,连同其中的应答 future。为它们仍在进行的交换都会被取消,路由得到的 stream 也随之关闭。 - supervisor 关闭(
Actor::shutdown)时,先停止每个监听器,并取消每个负载均衡器的探测 token。然后关闭 task tracker,并在宽限期内等待其上的任务结束。之后取消根 token,结束仍在运行的会话;它们的运行时会 drop link 以及其中的应答 future。本页的组件都不需要单独停止。最后一个Supervisorhandle 在没有 shutdown 的情况下被 drop 时,actor 以零宽限期执行同样的流程。 - 一次应用不会 spawn 或取消本页涉及的任何任务,负载均衡器的健康探测除外:
commit会取消每个被重建或删除的负载均衡器的探测 token,而 DNS 变更会重建每个负载均衡器。关闭某个出站版本的排空会结束运行在该版本上的路由查询 stream,如上表所示。
| 常量 | 文件 | 值 | 限制什么 |
|---|---|---|---|
MAX_PENDING |
supervisor/src/topology/outbound/dns.rs |
64 | 一个 DnsUdpLink 上处理中的查询数。更多的数据报会被丢弃。 |
MAX_MESSAGE |
supervisor/src/topology/outbound/dns.rs |
65,535(u16::MAX) |
最大的 DNS 报文。DnsTcpStream 最多缓冲 MAX_MESSAGE + 2 = 65,537 个写入字节,之后写入就要等待。 |
DNS_PORT |
supervisor/src/topology/plane.rs |
53 | 服务存在时在 TCP 和 UDP 上拦截的端口 |
DNS_FLOW_TAG |
supervisor/src/topology/outbound/dns.rs |
"dns" |
解析器自己的流的入站 tag |
服务的上限按 link 计:每个 DnsUdpLink 最多 64 个处理中的查询,每个 DnsTcpStream 一个。关联只把新查询发给当前 DNS 目标上的 DnsUdpLink。DNS 变更后 fan-out 可能仍持有旧的那个,它只交付已在等待的应答。
每个解析器有自己的缓存。Single 只有一个解析器,因此只有一个缓存。through_proxy 指定了服务器时,Split 有两个(direct 和 proxied),否则只有一个。重建的配置从空缓存开始。解析器自身的限制(查询超时、缓存大小、TTL 限制范围和 UDP 缓冲区大小)见 DNS 解析器。
| 层 | 文件 | 覆盖内容 |
|---|---|---|
| 服务出站 | supervisor/tests/unit/dns_outbound.rs |
tcp_answers_pipelined_queries_in_order_then_ends:背靠背写入两个查询再 shutdown,按顺序读到应答,随后流结束。udp_answers_from_the_address_asked:应答的来源就是被查询的 Destination。两者都使用一个基于 hosts 表(192.0.2.1 one.test、192.0.2.2 two.test)且没有服务器的服务,因此不涉及网络。 |
| plane | supervisor/tests/unit/plane.rs |
port_53_is_intercepted_except_for_the_services_own_queries(发往 53 端口的 TCP 和 UDP 到达 DNS 目标,route_upstream 从不返回它,443 端口不被拦截)、without_a_dns_service_port_53_follows_the_route_table、the_empty_plane_drops_everything |
| 规划 | supervisor/tests/unit/plan.rs |
a_dns_change_rebuilds_every_outbound_but_a_blackhole(切换到 Split 会把 direct 和 proxy 重建为版本 2,复用 hole,为两个旧版本各安排一个策略为 Keep 的 Drain 步骤,并发布 plane)、a_route_change_alone_rebuilds_only_the_route_table(Reuse(Resource::Dns))、the_first_plan_binds_and_builds_everything_then_publishes(Build(Resource::Dns) 排在最前) |
| 解析器构建 | protocols/tests/unit/dns/mod.rs |
a_named_server_without_a_bootstrap_is_refused、a_query_socket_gets_the_socket_policy、a_server_url_is_read_in_every_documented_form |
| 服务应答 | protocols/tests/unit/dns/serve.rs |
a_query_is_relayed_and_its_response_handed_back_untouched、withheld_aaaa_is_an_empty_answer_without_asking_anyone、the_hosts_entries_answer_before_any_server、an_oversized_udp_response_is_truncated_but_tcp_gets_it_whole、a_dead_server_is_skipped_for_the_next、garbage_gets_no_reply |
| app lowering | app/tests/unit/lower.rs |
a_server_config_lowers_to_its_spec(没有 [dns] 时为 Single(Backend::System))、a_dns_backend_needs_what_it_names、a_subscribe_file_splits_dns(lowering 会把 system 保留在 through_proxy 中,拒绝它的是 build_dns) |
| app 订阅文件 | app/tests/unit/subscribe.rs |
every_lowered_node_builds_and_the_dns_setup_with_it:示例订阅文件的 Split 配置(一个 UDP pre_proxy 服务器、DoH 和 DoT through_proxy 服务器、use_hosts)通过 check |
| app 端到端 | app/tests/integration/e2e_dns.rs |
只有一个假 UDP 服务器知道的名称,通过 udp 后端能解析,通过默认后端则不能;没有 server 的 udp 后端让 --test 失败 |
修改这里时应当用测试补上的空缺:
- 没有测试让查询经过
RoutedDialer和一个真实的出站。 - 没有测试覆盖
build_dns的Split分支中check所构建内容之外的部分,也没有测试检查prepare是否保留复用的配置。 NOTIMP应答,以及没有可中继服务器时的应答,在protocols/tests/unit/dns/serve.rs中都没有测试。
各测试套件的组织方式见测试。