跳转到内容

名称解析与 DNS 服务

源码文件:33 个 · 核对版本 Etemenanki 555b7df · katana v4.1.1
  • Etemenanki/supervisor/src/build/dns.rs
  • Etemenanki/supervisor/src/topology/outbound/dns.rs
  • Etemenanki/supervisor/src/topology/spec_plan/dns.rs
  • Etemenanki/supervisor/src/topology/spec_plan/mod.rs
  • Etemenanki/supervisor/src/topology/spec_plan/plan.rs
  • Etemenanki/supervisor/src/topology/plane.rs
  • Etemenanki/supervisor/src/topology/outbound/mod.rs
  • Etemenanki/supervisor/src/topology/outbound/udp_fanout.rs
  • Etemenanki/supervisor/src/topology/flow.rs
  • Etemenanki/supervisor/src/connector.rs
  • Etemenanki/supervisor/src/supervisor.rs
  • Etemenanki/supervisor/src/build/outbound.rs
  • Etemenanki/supervisor/src/build/apply.rs
  • Etemenanki/supervisor/src/build/validate.rs
  • Etemenanki/supervisor/src/entity/id.rs
  • Etemenanki/protocols/src/dns/mod.rs
  • Etemenanki/protocols/src/dns/serve.rs
  • Etemenanki/protocols/src/dns/hosts.rs
  • Etemenanki/app/src/lower.rs
  • Etemenanki/app/src/subscribe.rs
  • Etemenanki/app/src/instance.rs
  • Etemenanki/app/src/main.rs
  • Etemenanki/subscribe/src/dns.rs
  • Etemenanki/supervisor/tests/unit/dns_outbound.rs
  • Etemenanki/supervisor/tests/unit/plane.rs
  • Etemenanki/supervisor/tests/unit/plan.rs
  • Etemenanki/protocols/tests/unit/dns/mod.rs
  • Etemenanki/protocols/tests/unit/dns/serve.rs
  • Etemenanki/app/tests/unit/lower.rs
  • Etemenanki/app/tests/unit/subscribe.rs
  • Etemenanki/app/tests/integration/e2e_dns.rs
  • katana/src/lower/outbound.rs
  • katana/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 从哪里来。
supervisor/src/topology/spec_plan/dns.rs
#[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 应答。解析器自己为出站和健康探测所做的查询不受影响。
supervisor/src/build/dns.rs
#[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。

supervisor/src/build/dns.rs (Single arm, abridged)
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 不拦截任何流。
supervisor/src/build/dns.rs (Split arm, abridged)
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,
})

代码最多构建两个解析器,顺序如下:

  1. hosts 表。 开启 use_hosts 时,protocols/src/dns/hosts.rs → Hosts::system 读取 /etc/hosts(Windows 上是 C:\Windows\System32\drivers\etc\hosts)。文件不存在时得到一张空表,地址无法解析的行会被跳过。其他任何读取错误都会让构建失败。两个解析器通过一个 Arc 共享同一张表。

  2. direct,基于 pre_proxy 服务器。它有 hosts 表和 socket 策略,但没有拨号器和 bootstrap,所以它的服务器必须写成地址。它可以包含 system。

  3. 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 的克隆,共享它的缓存。

  4. 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 解析器。

protocols/src/dns/mod.rs
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 的,但不使用:每个连接都经过拨号器,而拨号器选中的出站在同一策略下拨号
protocols/src/dns/serve.rs
#[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 把服务包装成一个它自己的出站:

supervisor/src/topology/outbound/mod.rs
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 下,见流跟踪、统计与限速。

supervisor/src/topology/plane.rs
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 端口和其他一切一起被丢弃。

supervisor/src/topology/outbound/dns.rs
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 允许的。

supervisor/src/topology/outbound/dns.rs
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:

  1. 客户端关闭发送方向后,返回 BrokenPipe;
  2. 计算 room = (MAX_MESSAGE + 2).saturating_sub(inbound.len()),即一整个报文加上长度前缀的空间(65,537 字节);
  3. 没有空间时,存下 write_waker 并返回 Pending,这样写得比应答被读走更快的客户端就要等待;
  4. 否则追加最多 room 个字节,唤醒挂起的读取方,并返回接收了多少字节。

poll_flush 什么也不做。poll_shutdown 设置 write_closed 并唤醒挂起的读取方。

读取。 poll_read 循环执行以下四步,每轮采用第一个适用的步骤:

  1. 未读的应答字节。 把能放下的部分复制到调用方的缓冲区并返回。缓冲区被读到末尾后清空。
  2. 处理中的应答。 poll 它,未完成则返回 Pending。完成时清空 answering。Some(response) 连同它的 2 字节大端序长度一起追加到 outbound。None(这些字节不是查询)不产生任何帧,与 UDP 上相同。
  3. inbound 中有完整的查询。 在 inbound 同时有两个长度字节和其后的完整报文之前,take_query 都返回 None。凑齐之后,它从 inbound 中移除前缀和报文,唤醒挂起的写入方,并返回该报文。循环随即启动 answer(query, Transport::Tcp)。
  4. 无事可做。 如果客户端已关闭发送方向,返回 Ready 且什么也没读到,即流结束。否则存下 read_waker 并返回 Pending。

由这个顺序可以得出:

  • 应答按顺序逐个给出。 同一时刻只存在一个应答 future,并且只有在上一个应答被完整读出之后才会取下一个查询。客户端可以流水线式地发送查询,但它们按写入顺序应答。
  • 流只在最后一个应答之后结束。 shutdown 之后,读取会继续,直到每个完整的查询都已应答并被读出。客户端只写了一部分的查询被丢弃,然后流结束。
  • 缓冲区有上限。 inbound 永远不超过 65,537 字节,outbound 最多持有一个带帧的应答。
  • 只有流被读取时应答才会推进。 应答 future 只在 poll_read 中被 poll,别处不会。
supervisor/src/topology/outbound/dns.rs
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 时同步选定:

  1. 它构建 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() 的空用户名和空密码。
  2. 它构建 FlowContext { inbound_tag: "dns", source: None },以 DNS_FLOW_TAG 作为入站 tag。
  3. 它升级弱引用,加载当前 plane,取 route_upstream(&flow, &ctx).target。Routed 同时携带的匹配规则被丢弃。

连接在返回的 future 中打开:

  1. 如果升级失败,future 以 NotConnected 失败:dns: the supervisor this resolver dials through is gone。
  2. 否则它调用 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 入站的任务。

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 回复中给出的地址,所以它不解析任何名称。

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
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["出站或负载均衡器"]

下面的时序图展示了在指定了 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 服务器通信。

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 的字节)。

supervisor/src/supervisor.rs (Actor::prepare, abridged)
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 目标不在 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。

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,地址均为占位值:

sub.toml (subscribe file, excerpt)
[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 = true
connect_ipv6 = "preferred"
resolve_ipv6 = true
the lowered DnsSpec
DnsSpec::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(在解析器层面)

以下每一种情况都会让 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。本页的组件都不需要单独停止。最后一个 Supervisor handle 在没有 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 中都没有测试。

各测试套件的组织方式见测试。