Skip to content

Name resolution and the DNS service

Source files: 33 · checked against 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

A supervisor resolves names for three kinds of work: finding the proxy servers its outbounds dial, finding the destinations it connects to itself, and, on a client, answering the DNS queries that applications send through it. One field of the spec, Spec::dns, describes all three. supervisor/src/build/dns.rs → build_dns turns it into a Dns value: a resolver for proxy servers, a resolver for destinations, and an optional DNS service. When the service exists, the plane sends every application flow to port 53 to it. When the spec names servers to reach through the proxy, the resolver’s questions to them leave through the route table, like any other traffic.

This page is for contributors who change how the supervisor builds its resolvers, which consumer gets which one, the DNS service and its outbound, or RoutedDialer. The resolver itself (backends, the cache, lookups and the wire codec) is on DNS resolver. For the operator’s view of the app’s [dns] table, see the DNS guide page.

Component File → symbol Owns
Desired state supervisor/src/topology/spec_plan/dns.rs → DnsSpec The two shapes of name resolution, Single and Split, as comparable values
Construction supervisor/src/build/dns.rs → build_dns, Dns Building the resolvers a spec describes and the service over one of them
Answering protocols/src/dns/serve.rs → Service Deciding what each application query gets: a relayed response, an answer of its own, or nothing
Service outbound supervisor/src/topology/outbound/mod.rs → Outbound::Dns; supervisor/src/topology/outbound/dns.rs → DnsUdpLink, DnsTcpStream Carrying an application’s port-53 flow to the service, one datagram per query over UDP and length-prefixed messages over TCP
Interception supervisor/src/topology/plane.rs → Plane::route, DNS_PORT Sending every application flow to port 53 to the service when one exists
Resolver egress supervisor/src/topology/outbound/dns.rs → RoutedDialer; Plane::route_upstream Opening the through-proxy resolver’s connections as flows through the route table, which never intercepts them
Lifecycle supervisor/src/supervisor.rs → Actor::prepare, Actor::commit; supervisor/src/topology/spec_plan/plan.rs → plan Rebuilding the resolvers only when DnsSpec changes, and every outbound that holds them

What it leaves to others:

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 is the dns field of Spec (supervisor/src/topology/spec_plan/mod.rs). It holds etemenanki_protocols::dns::Backend values, never the strings they were parsed from. Backend derives PartialEq, so it compares by its server address, TLS server name, DoH host and path, and the bytes of any CA it pins. Two specs that differ in any of these are different specs. The planner relies on that equality (see Across applies).

Variant Field Meaning
Single(backend) backend One resolver for everything: the proxy servers the outbounds dial and the destinations this host reaches itself. There is no DNS service, and applications’ DNS is routed like any other flow.
Split pre_proxy Servers asked directly from this host. They look up the proxy servers and the names of the through_proxy servers. Tried in order.
through_proxy Servers reached through the route table. They look up everything else, including every query applications send. Tried in order. An empty list means the pre_proxy servers answer those too.
use_hosts Answer from the system hosts file before asking any server.
resolve_ipv6 Whether applications get AAAA answers. The resolvers’ own lookups, for outbounds and probes, are not affected.
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>;

Both items are crate-private. Callers see only the effect: the outbounds, probes and plane that a spec builds. The doc comment on Dns states the rule the planner follows: every outbound but a blackhole holds one of these resolvers, so a changed spec rebuilds them all.

Parameter Type Role
spec &DnsSpec The desired resolution
plane Weak<ArcSwap<Plane>> A weak reference to the supervisor’s plane cell. Only the through-proxy resolver of a Split spec uses it, through RoutedDialer.
socket &SocketOptions The supervisor’s socket policy. Every resolver that opens sockets from this host opens them under it.

The socket policy is set once with SupervisorBuilder::socket_options. Its doc comment says it holds for the supervisor’s lifetime and that an apply cannot change it. It covers every query a resolver sends from this host, but not the system resolver: getaddrinfo opens its sockets inside the C library, where no policy reaches them.

Resolver and Service are cheap to clone: each is an Arc inside. servers and destinations may be clones of one resolver, and then they share one cache. build_dns never calls Resolver::with_ttl_bounds, so every resolver it builds clamps TTLs with the resolver’s default bounds.

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 })

One resolver serves as both servers and destinations. The code comment gives the reason: the outbounds resolve overlapping sets of names, and one shared cache is the point of having a resolver at all.

  • Backend::System becomes Resolver::system(), the cached host resolver. getaddrinfo opens its sockets inside the C library, so the socket policy does not reach it. getaddrinfo also reads /etc/hosts itself.
  • Any other backend becomes a one-server resolver under the socket policy, with no hosts table, no dialer and no bootstrap. A server written by name therefore cannot be used here. Resolver::with_upstreams refuses it with dns: server "<host>" is a name, and nothing resolves it; write its address. The app and katana never produce such a spec: their [dns] parser reads server as a socket address (see Where a DnsSpec comes from).
  • service is None, so the plane intercepts nothing.
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,
})

The code builds up to two resolvers, in this order:

  1. The hosts table. With use_hosts, protocols/src/dns/hosts.rs → Hosts::system reads /etc/hosts (C:\Windows\System32\drivers\etc\hosts on Windows). A missing file is an empty table, and a line whose address does not parse is skipped. Any other read error fails the build. Both resolvers share the one table through an Arc.

  2. direct, over the pre_proxy servers. It has the hosts table and the socket policy, but no dialer and no bootstrap, so its servers must be addresses. It may include system.

  3. proxied, over the through_proxy servers:

    • dialer is a RoutedDialer over the plane cell, so every connection this resolver makes is a flow through the route table. Plain DNS servers are then asked over TCP (RFC 7766), because the resolver sends plain DNS over TCP whenever it has a dialer.
    • bootstrap is direct, so a through_proxy server may be written by name. Its name is looked up by the pre_proxy servers, from this host.
    • system is refused here: dns: the system resolver cannot be reached through a proxy. getaddrinfo takes a name, not a connection, so there is nothing to route.
    • The socket policy is passed along, but with a dialer present this resolver opens no socket of its own. Its connections are opened by whichever outbound the route table picks. Every outbound is built with the supervisor’s one socket policy (build_outbound(outbound, &dns, &self.socket)), so the connection leaves under that policy, or inside the tunnel for WireGuard.

    When through_proxy is empty, proxied is a clone of direct, sharing its cache.

  4. Service::new(proxied, resolve_ipv6), the service applications’ queries are answered by.

Dns field Split value Resolves
servers direct Proxy server names, and nothing routed
destinations proxied Destination names, answered by the through_proxy servers, or by pre_proxy when that list is empty
service Some(Service::new(proxied, resolve_ipv6)) Applications’ queries

With both lists empty, direct has no server at all. Resolver::with_upstreams accepts that: such a resolver answers only from the hosts table and address literals. Every other name then fails with dns: <host> is not in the hosts file and no server is configured, and the service answers an A or AAAA question for it with NXDOMAIN.

build_dns uses these items of etemenanki_protocols::dns. How the resolver uses them is on DNS resolver.

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>;
}
Resolver hosts dialer bootstrap socket
Single(System) none (getaddrinfo reads the file itself) none none default, unused
Single(other) none none none the supervisor’s
Split direct the table if use_hosts none none the supervisor’s
Split proxied the same table if use_hosts RoutedDialer direct the supervisor’s, unused: every connection goes through the dialer, and the outbound it picks dials under the same policy
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 returns None for bytes that are not a query, and the caller then sends nothing. Otherwise it returns a response, decided by the private respond in this order:

Question Response
Bytes that do not decode as a query None, and dns service: dropping an undecodable query: <error> at debug
An opcode other than 0 (a standard query) NOTIMP, with no records
AAAA in class IN, when the service was built with resolve_ipv6 false An empty NOERROR, without asking anyone
A or AAAA in class IN, for a name in the resolver’s hosts table NOERROR with the table’s addresses, TTL SYSTEM_TTL
Anything else Resolver::relay: the query is sent as written to each server in turn, and the first response whose id matches is returned untouched
relay fails with Unsupported (dns: no server that a query can be relayed to) A or AAAA: an answer from the resolver’s own lookup (resolve_with_ttl): NOERROR with the addresses, NXDOMAIN when the lookup reports the name as not found, SERVFAIL otherwise. Any other question: an empty NOERROR.
relay fails otherwise SERVFAIL, and dns service: <name> failed: <error> at debug

A TTL on records the service builds itself is in whole seconds and at least 1 (ttl_secs). relay skips system servers, because getaddrinfo takes a name rather than a message, so Unsupported means the resolver has no server that takes a message. In a supervisor that happens only under Split with an empty through_proxy, when pre_proxy is empty or holds only system.

Over Transport::Udp, a response larger than the query’s limit is replaced by an empty one with the truncation bit set. The limit is the EDNS payload size the client advertised, and never less than the classic 512 bytes (Query::udp_limit). The client then asks again over TCP, where any size fits.

The supervisor wraps the service in an outbound of its own:

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),
}
Call Outbound::Dns returns
connect_stream(flow) A ready future of OutboundStream::Proxy(Box::pin(DnsTcpStream::new(service)))
connect_datagram(flow) A ready future of OutboundDatagram::new(DnsUdpLink::new(service))

Neither call looks at the flow’s destination. Every query is answered by the service, whatever address the application sent it to.

Actor::prepare turns Dns::service into a plane target, Target::outbound(internal_id(epoch + 1), Outbound::Dns(service)). internal_id gives it an empty tag and a version equal to the epoch of the plane that will first carry it, so at least 1. Validation refuses an empty tag in a spec (supervisor/src/build/validate.rs → unique_tags, with a <kind> tag must not be empty), and its doc comment says why: the empty tag is the supervisor’s own, for the targets it makes itself. So this id never names a spec outbound. The only other target with an empty tag is the blackhole of Plane::empty, at version 0. OutboundId displays as <tag>@v<version> (supervisor/src/entity/id.rs), so this id prints as @v<version>. The flows opened on it are filed under that id: see Tracking flows, stats and speed limits.

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 is what every application flow is routed with. supervisor/src/connector.rs → AppConnector::connect calls it for each TCP flow, and FanOutLink::poll_send_to calls it for each UDP packet. When dns is set and flow.destination.port == DNS_PORT, it returns the DNS target with rule: None, without consulting the route table. Otherwise it defers to route_upstream.
  • route_upstream asks the route table alone and returns the slot’s target with the rule that matched. RoutedDialer uses it, so the service’s own questions are never sent back to the service.

Interception keys on the port alone, over TCP and UDP. The comment on route gives the reason: an application falls back to TCP when a UDP answer comes back truncated, and that retry has to reach the same service. The destination address, the inbound and any sniffed name play no part. The plane that exists before the first apply (Plane::empty) has no DNS target and a single blackhole slot, so port 53 is dropped along with everything else.

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
}

The module topology::outbound::dns is public, so DnsUdpLink, DnsTcpStream and RoutedDialer and their new constructors are public API of etemenanki-supervisor (etemenanki_supervisor::topology::outbound::dns). The private helper answer is shared by both links: it clones the Service into a boxed future that owns the query and awaits Service::answer(&query, transport).

A DnsUdpLink is a sub-link of a UDP association’s fan-out (supervisor/src/topology/outbound/udp_fanout.rs → FanOutLink). The fan-out keys its sub-links by target id, so it opens a DnsUdpLink when the association sends to port 53 and holds no sub-link on the current DNS target: on its first port-53 packet, after a DNS change gave the target a new id, or after the fan-out dropped the previous one. The fan-out keeps at most MAX_SUBS (64) sub-links and drops the least recently sent-to one past that; a dropped DnsUdpLink takes the answers pending on it with it. A sub-link that fails is dropped too. The sub-link table is described on Outbounds, UDP fan-out and balancers. Each datagram is one query.

  • poll_send_to(buf, to) copies buf into a Vec and starts answer(query, Transport::Udp). It pushes that future with to onto pending and wakes a parked receiver. When MAX_PENDING (64) queries are already in flight on this link, the datagram is dropped instead and dns service: 64 queries in flight, dropping one is logged at debug. The code comment says why: a client past that limit is flooding, and a busy server would drop its extra datagrams too. Either way the call returns Poll::Ready(Ok(buf.len())), so the sender sees a send, as on a lossy network. It never fails.
  • poll_recv_from(buf) polls every pending future in turn. The first one that completes is taken out with swap_remove. A None answer (the datagram was not a query) is discarded and the scan goes on. A Some(response) is copied into buf and returned with the Destination the query was sent to. A response larger than the reader’s buffer is cut to fit (response.len().min(buf.remaining())), as a socket cuts a datagram. The application checks the source of a reply against the server it asked, so the answer has to come from that address. When nothing is ready, the task’s waker is stored in recv_waker and the call returns Pending.

The answer futures are polled only from poll_recv_from. They make progress while the link’s owner is receiving from it, and on no task of their own. swap_remove does not keep order, so answers can come back in a different order from their queries, which is what UDP allows.

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 */ }

One DnsTcpStream is one application TCP connection to port 53. It carries DNS-over-TCP framing (RFC 1035 §4.2.2): each message behind a 2-byte big-endian length. The runtime relaying the connection writes the client’s bytes into it and reads the answers back.

Writes. poll_write:

  1. returns BrokenPipe once the client has shut down its sending side;
  2. computes room = (MAX_MESSAGE + 2).saturating_sub(inbound.len()), which is space for one whole message and its length prefix (65,537 bytes);
  3. with no room, stores write_waker and returns Pending, so a client that writes faster than its answers are read waits;
  4. otherwise appends up to room bytes, wakes a parked reader, and returns how many it took.

poll_flush is a no-op. poll_shutdown sets write_closed and wakes a parked reader.

Reads. poll_read loops over four steps and takes the first that applies:

  1. Unread answer bytes. Copy as many as fit into the caller’s buffer and return. The buffer is cleared once it has been read to the end.
  2. An answer in flight. Poll it, and return Pending if it is not done. When it completes, answering is cleared. A Some(response) is appended to outbound behind its 2-byte big-endian length. A None (the bytes were not a query) frames nothing, as over UDP.
  3. A complete query in inbound. take_query returns None until inbound holds both length bytes and the whole message behind them. It then removes the prefix and the message from inbound, wakes a parked writer, and returns the message. The loop starts answer(query, Transport::Tcp).
  4. Nothing to do. If the client has shut down, return Ready with nothing read, which is end of stream. Otherwise store read_waker and return Pending.

From this order it follows that:

  • Answers are in order, one at a time. Only one answer future exists at a time, and the next query is taken only after the previous answer has been read out completely. A client may pipeline queries, but they are answered in the order written.
  • The stream ends only after the last answer. After shutdown, reads go on until every complete query has been answered and read. A query the client had written only in part is discarded, and the stream then ends.
  • Buffers stay bounded. inbound never holds more than 65,537 bytes, and outbound holds at most one framed answer.
  • Answers make progress only while the stream is read. The answer future is polled from poll_read and nowhere else.
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) is called by the through-proxy resolver each time it needs a connection to one of its servers. It works in two stages.

The route is chosen synchronously, when dial is called:

  1. It builds Flow::new(Destination { network: DialNetwork::Tcp, remote: Remote::IpAddr(server.ip()), port: server.port() }, anonymous_user(), None). The flow has no source, and its user is supervisor/src/topology/flow.rs → anonymous_user, an empty username and password over Principal::anonymous().
  2. It builds FlowContext { inbound_tag: "dns", source: None }, with DNS_FLOW_TAG as the inbound tag.
  3. It upgrades the weak reference, loads the current plane, and takes route_upstream(&flow, &ctx).target. The matched rule that Routed also carries is discarded.

The connection is opened in the returned future:

  1. If the upgrade failed, the future fails with NotConnected, dns: the supervisor this resolver dials through is gone.
  2. Otherwise it calls target.connect_stream(flow). A balancer resolves to its member at that moment. Were that member a balancer itself, connect_stream would fail with balancer member <id> is itself a balancer; validation refuses such a spec. The resulting Guarded<OutboundStream> is returned boxed as a BoxedDnsStream.

What this means for the resolver’s traffic:

  • Each exchange follows the route table current when it is sent. The resolver opens a new connection for each exchange with a server, and dial loads the plane each time, so a route change reaches the next exchange. A name lookup asks A and AAAA concurrently, so it calls dial twice for each server it tries. A relayed application query calls it once for each server tried.
  • Rules see a TCP flow to an IP address. The flow’s port is the server’s port, by default 53 for plain DNS (asked over TCP here), 853 for DNS over TLS and 443 for DNS over HTTPS. Its network is TCP and its inbound tag is dns, with no source and no sniffed name. CIDR, GeoIP, port, network and inbound_tag rules can match it. Domain rules cannot, because the destination is an address. Validation does not reserve the tag dns: an inbound tagged dns is matched by the same inbound_tag rules as the resolver’s flows.
  • The streams are not metered. RoutedDialer opens the target directly, not through AppConnector. These flows belong to no session, are never registered with the tracker, have no FlowMeta recording their rule or epoch, and count toward no user’s usage. They do carry the Guarded wrapper, so a drain that closes the outbound version they run on ends them with the outbound this flow was opened on was drained.
  • TLS is end to end. For DNS over TLS and DNS over HTTPS, the resolver runs its TLS client over the routed stream. The outbound carries the encrypted bytes and never sees the query.

The reference to the plane is weak because of a cycle. The plane cell holds the current plane, the plane holds its targets, the targets hold outbounds and the DNS service built with proxied, and proxied holds this dialer. A strong reference would keep the cell and its current plane alive forever. The upgrade fails only after every strong holder of the cell has been dropped, such as the actor’s shared state, every Supervisor handle, the connectors, the fan-out links and a TUN inbound’s task.

supervisor/src/build/outbound.rs → build_outbound(spec, &dns, socket) hands each outbound the resolver for what it looks up. Actor::commit hands each new balancer’s probes dns.servers.

Consumer Where it gets its resolver Resolver Looks up
Transports of SOCKS, HTTP, Trojan, VLESS, VMess, Shadowsocks and Shadowsocks 2022 outbounds build/outbound.rs → transport → TransportConnector::new(kind, dialer, dns.servers, address_family) servers The proxy server’s host name
Hysteria 2 outbound Hy2Connector::with_address_family(...).with_resolver(servers) servers The Hysteria 2 server’s host name
WireGuard endpoint WgConfig::endpoint_resolution = Some((servers, endpoint_address_family)) servers The peer endpoint’s host name
WireGuard tunnel WgConnector::with_address_family(...).with_resolver(dns.destinations) destinations Names reached inside the tunnel
Freedom outbound, TCP and UDP FreedomConnector::new(Dialer::new(socket), dns.destinations, address_family) destinations Destination names
Balancer health probes Balancer::spawn_probe(..., dns.servers, TcpDialer::new(socket)) in Actor::commit servers Each member’s upstream server, under AddressFamilyStrategy::Auto
DNS service Service::new(proxied, resolve_ipv6) destinations (proxied) Applications’ queries
Blackhole outbound none none nothing

Under Single, every row gets the same resolver. Under Split, the table sorts lookups into two groups:

  • Anything needed to reach a proxy uses servers, which asks from this host. A proxy server’s name has to be known before the proxy can carry anything, so resolving it through the proxy would be circular.
  • Anything that is a destination uses destinations, whose queries travel through the route table when through_proxy names servers. This covers the destinations freedom dials, the names inside a WireGuard tunnel, and every name an application asks for.

The SOCKS outbound’s UDP relay sends to the address the server’s UDP ASSOCIATE reply names, so it resolves nothing.

flowchart LR
  spec["DnsSpec::Split"]
  hosts["Hosts::system, with use_hosts"]
  direct["direct resolver: pre_proxy"]
  proxied["proxied resolver: through_proxy"]
  dialer["RoutedDialer, weak plane cell"]
  service["Service, resolve_ipv6"]
  servers["Dns.servers"]
  dests["Dns.destinations"]
  target["Outbound::Dns target in Plane.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["application flow or UDP packet"] --> route["Plane::route"]
  route --> check{"Plane.dns set and port 53?"}
  check -->|yes| dns["DNS target: DnsTcpStream or DnsUdpLink"]
  check -->|no| table["route table"]
  own["resolver connection from RoutedDialer"] --> upstream["Plane::route_upstream"]
  upstream --> table
  table --> out["outbound or balancer"]

The sequence below shows a UDP query under a split setup with through_proxy servers. A TCP query takes the same path through AppConnector::connect and DnsTcpStream instead of the fan-out and DnsUdpLink.

sequenceDiagram
  participant App as application
  participant RT as inbound runtime
  participant F as FanOutLink
  participant L as DnsUdpLink
  participant S as Service
  participant R as proxied Resolver
  participant D as RoutedDialer
  participant O as routed outbound
  App->>RT: query to port 53 over UDP
  RT->>F: poll_send_to(query, to)
  F->>F: Plane::route picks the DNS target
  F->>L: poll_send_to, queued if fewer than 64 in flight
  RT->>F: poll_recv_from
  F->>L: poll_recv_from polls the answer futures
  L->>S: answer(query, Transport::Udp)
  S->>R: relay(query)
  R->>D: dial(server)
  D->>D: Plane::route_upstream, inbound tag dns
  D->>O: connect_stream(flow)
  O-->>R: stream to the server
  R-->>S: response with a matching id
  S-->>L: response, or a truncated one if too big
  L-->>F: answer from the address asked
  F-->>RT: datagram
  RT-->>App: answer

Up to the plane, a DNS flow is an ordinary flow. The connector admits the flow on its session, if the connector has one; a TUN inbound’s flows belong to no session. For UDP that admission happens once, when the association’s fan-out is opened. The TCP stream or the fan-out’s sub-link is metered as one flow, filed under the DNS target’s id (see Tracking flows, stats and speed limits). Every port-53 packet of one association is routed to the same target, so they share the association’s DnsUdpLink on that target, whichever server address the application asked.

Some queries finish without the route table:

  • A query the service answers itself (a non-standard opcode, a withheld AAAA, or a hosts entry) never reaches relay.
  • An empty through_proxy. proxied is then direct, so every relayed query is asked directly from this host, under the socket policy, and no RoutedDialer exists.
  • Nothing to relay to. With an empty through_proxy and a pre_proxy list that is empty or holds only system, relay fails with Unsupported. The service answers A and AAAA from its resolver’s own lookup (getaddrinfo for system, outside the socket policy), and any other question with an empty NOERROR.
  • Single. There is no service. A port-53 flow is then routed by the route table like any other flow, and the application talks to its own DNS server through whichever outbound the rules pick.

supervisor/src/topology/spec_plan/plan.rs → plan compares DnsSpec values with ==:

  • First apply, or a changed DnsSpec. The plan starts with Step::Build(Resource::Dns) and marks the plane as changed. Every outbound except a blackhole is then rebuilt as a new version, even when its own spec did not change, because every other kind of outbound holds one of the resolvers being replaced. The plan adds a Step::Drain for each old version (see Planning and applying a change). A balancer is rebuilt whenever one of its members is. Validation admits as members only outbounds with a TCP-reachable upstream (build/validate.rs → probe_target), which excludes blackhole, freedom, WireGuard and Hysteria 2 outbounds, so a DNS change rebuilds every balancer too. The plan publishes the plane (Step::PublishPlane), and the Drain steps for the old versions follow it at the end of the plan.
  • Unchanged DnsSpec. The plan holds Step::Reuse(Resource::Dns), and the outbounds are planned on their own specs alone.

Anything in the DnsSpec counts as a change, including the order of the server lists, a resolve_ipv6 or use_hosts flip, and a different pinned CA (a Backend compares CA bytes).

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)
}
};
Phase What happens to name resolution
prepare, first The DNS setup is settled before anything else, because every outbound is built with &dns. A reused setup is the same Dns value, with the same resolvers and warm caches, and the same DNS target Arc. A rebuilt one is new resolvers with empty caches and a new target. A build error returns ApplyError::Build { resource: Resource::Dns, source } before any outbound, route table, handler or listener is prepared.
prepare, outbounds build_outbound(outbound, &dns, &self.socket) for each Step::Build(Resource::Outbound(_))
commit, on PublishPlane Plane::new(routes, slots, dns_target.clone(), epoch) is stored in the plane cell with one atomic store. The routes, the outbounds and the DNS target change together. The probe token of every balancer the plan does not reuse is cancelled, and newly built balancers start their probes with dns.servers.
commit, afterwards self.dns = Some(dns) and self.dns_target = dns_target

Actor keeps the running setup in two fields: dns: Option<Dns> and dns_target: Option<Arc<Target>>, the internal target the DNS service is reached through. Prepared carries both from prepare to commit.

Because a reused setup keeps its target Arc, the DNS target keeps its id across an apply that leaves DnsSpec alone, such as a route change. A UDP association’s DNS sub-link, which the fan-out keys by that id, then stays in use.

The ApplyReport of an apply lists Resource::Dns, which displays as dns, under built or reused. etemenanki-app prints the report in its config loaded: and config reloaded: info lines (app/src/instance.rs → summary), so dns appears in the built [...] list when the setup was built and in the reused [...] list when it was kept.

supervisor/src/supervisor.rs → check, re-exported as etemenanki_supervisor::check, is what etemenanki-app and katana call for --test. It plans from an empty state and runs prepare on a fresh actor with SocketOptions::default() and without binding. So it builds the DNS setup too, reads the hosts file when use_hosts is set, and refuses what a start would refuse.

The DNS target is not one of the actor’s targets, the map that Step::Drain looks versions up in. No plan names a drain for it, so no drain policy and no close_flows applies to it. After a DNS change:

  • TCP. A connection to port 53 that is already open keeps its DnsTcpStream, and with it the old Service: the old servers, hosts table and resolve_ipv6, until the connection ends. Its relayed queries still follow the current route table, because RoutedDialer loads the plane cell on every dial.
  • UDP. An association’s next port-53 packet is routed to the new target, whose id differs, so the fan-out opens a DnsUdpLink on the new service. Answers already pending on the old sub-link still come back from it.
  • New flows reach only the new target.

No component on this page spawns a task:

  • The answer futures of DnsUdpLink and DnsTcpStream run inside the task that polls the link, usually the connection’s runtime. Dropping the link drops them, and with them any exchange in progress.
  • A resolver is reference-counted. It lives as long as something holds a clone: the actor’s Dns, the outbounds and probes built with it, and the DNS target with the links opened on it.
  • The resolver’s connections, routed or not, are owned by the query future that opened them and closed when it ends.
  • Balancer probes, which resolve with servers, run as tasks on the supervisor’s task tracker, each under a child token of the root. commit cancels that token when the plan does not reuse the balancer, because it was rebuilt or removed.

supervisor has no configuration format. Front ends lower their own format into a DnsSpec:

Front end Lowering Produces
etemenanki-app, [dns] app/src/lower.rs → lower_dns → lower_single_dns → Backend::from_spec Single(backend). Without [dns], Single(Backend::System).
etemenanki-app with a subscribe file that has [dns] app/src/lower.rs → lower_dns, each URL through Backend::from_url Split with pre_proxy, through_proxy, use_hosts and resolve_ipv6 copied from the file
katana, [dns] katana src/lower/outbound.rs → lower_dns, called from src/lower/mod.rs → shared_spec Single(backend) in every node’s spec

The app’s lowering is described on etemenanki-app: from TOML to a spec and Subscribe files. katana’s lowering is described on the katana pages. Points that matter here:

  • lower_single_dns reads a ca_file into the backend’s ca_pem. The app reads it through read_file, which names the key in its error. katana’s lower_dns reads it with std::fs::read and passes the OS error on as it is.
  • A subscribe file’s servers are URLs, and Backend::from_url sets ca_pem: None for every one. A pinned CA can therefore come only from the config’s [dns], which lowers to Single.
  • When a subscribe file has a [dns] section, the config’s own [dns] is ignored and never lowered. If the config’s [dns] differs from DnsConfig::default(), app/src/subscribe.rs → apply logs a warning (target etemenanki_app::subscribe) naming the subscribe file by its [subscribe].path as written, for example sub.toml has a [dns] section, so the config's [dns] is ignored.
  • A subscribe [dns] is the only way the app produces Split, so the app answers applications’ DNS itself only while such a file is in use.
  • katana runs one supervisor per node, so each node builds its own resolver with its own cache. katana never produces Split, so its nodes never intercept port 53. See katana internals.

The subscribe file’s [dns] table is subscribe/src/dns.rs → DnsConfig. It is deny_unknown_fields, and none of its keys has a default: a file that writes [dns] spells out all five, and one that leaves a key out, or adds one, fails to parse.

Key Type Lowered into
pre_proxy array of URL strings, may be empty Split::pre_proxy
through_proxy array of URL strings, may be empty Split::through_proxy
use_hosts bool Split::use_hosts
connect_ipv6 ProxyServerIpv6DnsPolicy: required, preferred, tolerated or forbidden Not part of DnsSpec. app/src/subscribe.rs → address_family maps it to ipv6_only, prefer_ipv6, prefer_ipv4 or ipv4_only, which becomes each node’s address_family, or endpoint_address_family for a WireGuard node.
resolve_ipv6 bool Split::resolve_ipv6

A subscribe file’s [dns] and the spec it becomes, with placeholder addresses:

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,
}
Invariant Mechanism Pinned by
Applications’ port-53 flows reach the service over both TCP and UDP when, and only when, the spec is Split Plane::route checks self.dns and DNS_PORT; Plane.dns is Some exactly when Dns::service is 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
The service’s own queries never come back to it RoutedDialer routes with route_upstream, which never returns the DNS target port_53_is_intercepted_except_for_the_services_own_queries
Before the first apply, port 53 is dropped like everything else Plane::empty has no DNS target and one blackhole slot plane.rs → the_empty_plane_drops_everything
A DNS change rebuilds the resolvers and every outbound except a blackhole, drains the old versions, and publishes a plane plan: dns_changed forces Step::Build of every outbound with uses_dns supervisor/tests/unit/plan.rs → a_dns_change_rebuilds_every_outbound_but_a_blackhole
An apply that leaves DnsSpec alone keeps the resolvers, their caches and the DNS target Step::Reuse(Resource::Dns); prepare clones self.dns and self.dns_target Only the planning half: plan.rs → a_route_change_alone_rebuilds_only_the_route_table checks for Reuse(Resource::Dns). No test checks that prepare then keeps the same resolvers and target.
The first plan builds DNS before any outbound plan pushes the DNS step first plan.rs → the_first_plan_binds_and_builds_everything_then_publishes
Over TCP, pipelined queries are answered in order on the same stream, and the stream ends once the client has shut down and been answered One answering future; take_query only after outbound is drained; end of stream only when write_closed and nothing is left supervisor/tests/unit/dns_outbound.rs → tcp_answers_pipelined_queries_in_order_then_ends
Over UDP, an answer comes back from the address the query was sent to pending stores to beside each future dns_outbound.rs → udp_answers_from_the_address_asked
A server the resolver could never use is refused when the spec is built, not when a query fails with_upstreams refuses a named server without a bootstrap, and system with a dialer protocols/tests/unit/dns/mod.rs → a_named_server_without_a_bootstrap_is_refused (the system refusal has no test)
--test builds the DNS setup a subscribe file describes check runs prepare, which calls build_dns app/tests/unit/subscribe.rs → every_lowered_node_builds_and_the_dns_setup_with_it
The app lowers [dns] to Single and a subscribe [dns] to 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
The configured backend really answers, and a missing server is refused Backend::from_spec and the Single arm 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
Queries asked from this host get the supervisor’s socket policy ResolverOptions::socket is the supervisor’s socket for every resolver that opens sockets itself protocols/tests/unit/dns/mod.rs → a_query_socket_gets_the_socket_policy (at the resolver level)

Each of these fails build_dns, so prepare returns ApplyError::Build { resource: Resource::Dns, source } (supervisor/src/build/apply.rs). It displays as building dns failed: <source>. Nothing of the apply becomes visible, and a running supervisor keeps everything it had.

Condition source
Split with use_hosts, and the hosts file exists but cannot be read The OS error, for example Permission denied (os error 13)
system among through_proxy dns: the system resolver cannot be reached through a proxy
A server written by name in pre_proxy, or in Single dns: server "<host>" is a name, and nothing resolves it; write its address
A DNS-over-TLS or DNS-over-HTTPS server whose pinned CA holds no certificate no certificate in CA PEM bundle
A pinned CA with a certificate block that does not parse An OpenSSL error from X509::stack_from_pem (protocols/src/transports/tls/config.rs)

Some mistakes never reach build_dns, because the front end refuses them first. What the pinned etemenanki-app prints for them with --test:

Mistake Refused by Line
[dns] ca_file that cannot be read 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 written as a name Backend::from_spec configuration invalid: dns: invalid server address: invalid socket address syntax
[dns] backend = "udp" without server Backend::from_spec configuration invalid: dns: the udp backend needs a server address
A subscribe server URL with an unknown scheme Backend::from_url configuration invalid: dns: server "ftp://192.0.2.53": unknown scheme "ftp"
A subscribe [dns] without use_hosts the subscribe file parser First line: configuration invalid: subscribe: TOML parse error at line 1, column 1

What etemenanki-app prints when build_dns fails. The --test lines were captured with the pinned binary. The start and reload lines are the formats in app/src/main.rs and app/src/instance.rs.

When Line (target etemenanki_app unless noted)
--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
Start failed to start: building dns failed: <source>
Reload (target etemenanki_app::instance) reload: building dns failed: <source>; keeping the running config
Where Condition Result
DnsUdpLink::poll_send_to 64 queries in flight on this link The datagram is dropped, the send reports success, and a debug line is logged
FanOutLink The fan-out drops a DnsUdpLink to stay within MAX_SUBS The answers pending on it are dropped with it
Service::answer The bytes are not a DNS query None: no datagram over UDP, nothing framed over TCP. Bytes on a TCP connection to port 53 that do not decode as a query get no answer.
Service::answer Relaying fails SERVFAIL. When there was nothing to relay to, the service’s own lookup decides instead (see The service and its target).
DnsTcpStream::poll_write The client already shut down its sending side BrokenPipe
DnsTcpStream The client shut down in the middle of a query The partial query is discarded and the stream ends
RoutedDialer::dial The plane cell is gone NotConnected, dns: the supervisor this resolver dials through is gone. The resolver tries its next server.
RoutedDialer stream The route picks a blackhole The stream reads end of stream at once. The exchange fails, and the resolver tries its next server.
RoutedDialer stream The outbound version it runs on is closed by a drain ConnectionAborted, the outbound this flow was opened on was drained. The exchange fails.
RoutedDialer stream The routed outbound cannot reach the server The outbound’s dial error. The exchange fails.
Resolver::relay A server’s response carries another id dns: relayed response does not match the query id. The resolver tries its next server.

A failed exchange never reaches the application directly. The resolver moves to its next server, and when none answers, Service turns the last error into an answer code.

All of these are at debug level:

Target Line
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>

Neither build_dns nor the service outbound logs anything above debug. Build errors reach the front end as ApplyError values, and the front end logs them.

  • A connection that ends drops its DnsTcpStream or its fan-out, with the answer futures inside. Any exchange still running for them is cancelled, and its routed stream is closed.
  • Supervisor shutdown (Actor::shutdown) first stops every listener and cancels every balancer’s probe token. It then closes the task tracker and waits up to the grace period for the tasks on it to end. After that it cancels the root token, which ends the sessions still running; their runtimes drop the links and the answer futures inside. Nothing on this page needs a stop of its own. When the last Supervisor handle is dropped without a shutdown, the actor runs the same sequence with a grace of zero.
  • An apply spawns and cancels no task on this page, apart from balancer probes: commit cancels the probe token of every balancer it rebuilds or removes, and a DNS change rebuilds every balancer. A drain that closes the outbound version a routed query’s stream runs on ends that stream, as in the table above.
Constant File Value Bounds
MAX_PENDING supervisor/src/topology/outbound/dns.rs 64 Queries in flight on one DnsUdpLink. Further datagrams are dropped.
MAX_MESSAGE supervisor/src/topology/outbound/dns.rs 65,535 (u16::MAX) Largest DNS message. DnsTcpStream buffers at most MAX_MESSAGE + 2 = 65,537 written bytes before a write waits.
DNS_PORT supervisor/src/topology/plane.rs 53 The port intercepted over TCP and UDP when a service exists
DNS_FLOW_TAG supervisor/src/topology/outbound/dns.rs "dns" Inbound tag of the resolver’s own flows

The service’s bounds are per link: at most 64 queries in flight per DnsUdpLink, and one per DnsTcpStream. An association sends new queries only to the DnsUdpLink on the current DNS target. After a DNS change the fan-out may still hold the old one, which delivers only the answers already pending on it.

Each resolver has its own cache. Single has one resolver, and so one cache. Split has two when through_proxy names servers (direct and proxied), and one otherwise. A rebuilt setup starts with empty caches. The resolver’s own limits (the query timeout, the cache size, the TTL clamp and the UDP buffer sizes) are on DNS resolver.

Layer File What it covers
Service outbound supervisor/tests/unit/dns_outbound.rs tcp_answers_pipelined_queries_in_order_then_ends: two queries written back to back and a shutdown; the answers are read in order and the stream then ends. udp_answers_from_the_address_asked: the answer’s source is the queried Destination. Both use a service over a hosts table (192.0.2.1 one.test, 192.0.2.2 two.test) and no servers, so no network is involved.
Plane supervisor/tests/unit/plane.rs port_53_is_intercepted_except_for_the_services_own_queries (TCP and UDP to port 53 reach the DNS target, route_upstream never does, port 443 is not intercepted), without_a_dns_service_port_53_follows_the_route_table, the_empty_plane_drops_everything
Planning supervisor/tests/unit/plan.rs a_dns_change_rebuilds_every_outbound_but_a_blackhole (a switch to Split rebuilds direct and proxy as version 2, reuses hole, plans a Drain step with policy Keep for both old versions, and publishes), a_route_change_alone_rebuilds_only_the_route_table (Reuse(Resource::Dns)), the_first_plan_binds_and_builds_everything_then_publishes (Build(Resource::Dns) comes first)
Resolver construction 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
Service answers 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 (no [dns] is Single(Backend::System)), a_dns_backend_needs_what_it_names, a_subscribe_file_splits_dns (lowering keeps system in through_proxy; build_dns is what refuses it)
App subscribe app/tests/unit/subscribe.rs every_lowered_node_builds_and_the_dns_setup_with_it: the example subscribe file’s split setup (a UDP pre_proxy server, DoH and DoT through_proxy servers, use_hosts) passes check
App end to end app/tests/integration/e2e_dns.rs A name only a fake UDP server knows resolves through the udp backend and not through the default; a udp backend without server fails --test

Gaps a change here should close with a test:

  • No test sends a query through RoutedDialer and a real outbound.
  • None covers the Split arm of build_dns beyond what check builds, or checks that prepare keeps a reused setup.
  • The NOTIMP answer and the answer when nothing can be relayed have no test in protocols/tests/unit/dns/serve.rs.

See Testing for how the suites are laid out.