Name resolution and the DNS service
Source files: 33 · checked against 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
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.
Responsibilities
Section titled “Responsibilities”| 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:
- Resolving. Lookups, the cache, TTL clamping, trying servers in order and relaying a raw query live in
etemenanki_protocols::dns. See DNS resolver. - Routing decisions.
Plane::route_upstreamasks the compiled route table. How rules match is on The plane: routing each flow and Route model. - Using the resolvers. Each outbound resolves its own names with the resolver it was built with. How outbounds dial is on Outbounds, UDP fan-out and balancers.
- Writing the spec. Front ends lower their own formats into
DnsSpec. See Where a DnsSpec comes from.
Key types
Section titled “Key types”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 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. |
Dns and build_dns
Section titled “Dns and 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>;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.
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 })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::SystembecomesResolver::system(), the cached host resolver.getaddrinfoopens its sockets inside the C library, so the socket policy does not reach it.getaddrinfoalso reads/etc/hostsitself.- 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_upstreamsrefuses it withdns: server "<host>" is a name, and nothing resolves it; write its address. The app and katana never produce such a spec: their[dns]parser readsserveras a socket address (see Where a DnsSpec comes from). serviceisNone, so the plane intercepts nothing.
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:
-
The hosts table. With
use_hosts,protocols/src/dns/hosts.rs→Hosts::systemreads/etc/hosts(C:\Windows\System32\drivers\etc\hostson 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 anArc. -
direct, over thepre_proxyservers. It has the hosts table and the socket policy, but no dialer and no bootstrap, so its servers must be addresses. It may includesystem. -
proxied, over thethrough_proxyservers:dialeris aRoutedDialerover 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.bootstrapisdirect, so athrough_proxyserver may be written by name. Its name is looked up by thepre_proxyservers, from this host.systemis refused here:dns: the system resolver cannot be reached through a proxy.getaddrinfotakes 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_proxyis empty,proxiedis a clone ofdirect, sharing its cache. -
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.
Resolver options
Section titled “Resolver options”build_dns uses these items of etemenanki_protocols::dns. How the resolver uses them is on DNS resolver.
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 |
The service and its target
Section titled “The service and its target”#[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:
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.
Interception in the plane
Section titled “Interception in the 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>,}routeis what every application flow is routed with.supervisor/src/connector.rs→AppConnector::connectcalls it for each TCP flow, andFanOutLink::poll_send_tocalls it for each UDP packet. Whendnsis set andflow.destination.port == DNS_PORT, it returns the DNS target withrule: None, without consulting the route table. Otherwise it defers toroute_upstream.route_upstreamasks the route table alone and returns the slot’s target with the rule that matched.RoutedDialeruses 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.
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}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)copiesbufinto aVecand startsanswer(query, Transport::Udp). It pushes that future withtoontopendingand wakes a parked receiver. WhenMAX_PENDING(64) queries are already in flight on this link, the datagram is dropped instead anddns service: 64 queries in flight, dropping oneis 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 returnsPoll::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 withswap_remove. ANoneanswer (the datagram was not a query) is discarded and the scan goes on. ASome(response)is copied intobufand returned with theDestinationthe 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 inrecv_wakerand the call returnsPending.
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.
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 */ }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:
- returns
BrokenPipeonce the client has shut down its sending side; - computes
room = (MAX_MESSAGE + 2).saturating_sub(inbound.len()), which is space for one whole message and its length prefix (65,537 bytes); - with no room, stores
write_wakerand returnsPending, so a client that writes faster than its answers are read waits; - otherwise appends up to
roombytes, 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:
- 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.
- An answer in flight. Poll it, and return
Pendingif it is not done. When it completes,answeringis cleared. ASome(response)is appended tooutboundbehind its 2-byte big-endian length. ANone(the bytes were not a query) frames nothing, as over UDP. - A complete query in
inbound.take_queryreturnsNoneuntilinboundholds both length bytes and the whole message behind them. It then removes the prefix and the message frominbound, wakes a parked writer, and returns the message. The loop startsanswer(query, Transport::Tcp). - Nothing to do. If the client has shut down, return
Readywith nothing read, which is end of stream. Otherwise storeread_wakerand returnPending.
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.
inboundnever holds more than 65,537 bytes, andoutboundholds at most one framed answer. - Answers make progress only while the stream is read. The answer future is polled from
poll_readand nowhere else.
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) 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:
- 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 issupervisor/src/topology/flow.rs→anonymous_user, an empty username and password overPrincipal::anonymous(). - It builds
FlowContext { inbound_tag: "dns", source: None }, withDNS_FLOW_TAGas the inbound tag. - It upgrades the weak reference, loads the current plane, and takes
route_upstream(&flow, &ctx).target. The matched rule thatRoutedalso carries is discarded.
The connection is opened in the returned future:
- If the upgrade failed, the future fails with
NotConnected,dns: the supervisor this resolver dials through is gone. - Otherwise it calls
target.connect_stream(flow). A balancer resolves to its member at that moment. Were that member a balancer itself,connect_streamwould fail withbalancer member <id> is itself a balancer; validation refuses such a spec. The resultingGuarded<OutboundStream>is returned boxed as aBoxedDnsStream.
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
dialloads the plane each time, so a route change reaches the next exchange. A name lookup asksAandAAAAconcurrently, so it callsdialtwice 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 andinbound_tagrules can match it. Domain rules cannot, because the destination is an address. Validation does not reserve the tagdns: an inbound taggeddnsis matched by the sameinbound_tagrules as the resolver’s flows. - The streams are not metered.
RoutedDialeropens the target directly, not throughAppConnector. These flows belong to no session, are never registered with the tracker, have noFlowMetarecording their rule or epoch, and count toward no user’s usage. They do carry theGuardedwrapper, so a drain that closes the outbound version they run on ends them withthe 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.
Which consumer uses which resolver
Section titled “Which consumer uses which resolver”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 whenthrough_proxynames 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.
Data flow
Section titled “Data flow”Building a split setup
Section titled “Building a split setup”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
Routing: intercepted and upstream
Section titled “Routing: intercepted and upstream”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"]
An application’s query
Section titled “An application’s query”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 reachesrelay. - An empty
through_proxy.proxiedis thendirect, so every relayed query is asked directly from this host, under the socket policy, and noRoutedDialerexists. - Nothing to relay to. With an empty
through_proxyand apre_proxylist that is empty or holds onlysystem,relayfails withUnsupported. The service answersAandAAAAfrom its resolver’s own lookup (getaddrinfoforsystem, outside the socket policy), and any other question with an emptyNOERROR. 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.
Across applies
Section titled “Across applies”Planning
Section titled “Planning”supervisor/src/topology/spec_plan/plan.rs → plan compares DnsSpec values with ==:
- First apply, or a changed
DnsSpec. The plan starts withStep::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 aStep::Drainfor 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 theDrainsteps for the old versions follow it at the end of the plan. - Unchanged
DnsSpec. The plan holdsStep::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).
Prepare and commit
Section titled “Prepare and commit”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.
Flows already open on the DNS target
Section titled “Flows already open on the DNS target”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 oldService: the old servers, hosts table andresolve_ipv6, until the connection ends. Its relayed queries still follow the current route table, becauseRoutedDialerloads 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
DnsUdpLinkon the new service. Answers already pending on the old sub-link still come back from it. - New flows reach only the new target.
Lifetimes
Section titled “Lifetimes”No component on this page spawns a task:
- The answer futures of
DnsUdpLinkandDnsTcpStreamrun 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.commitcancels that token when the plan does not reuse the balancer, because it was rebuilt or removed.
Where a DnsSpec comes from
Section titled “Where a DnsSpec comes from”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_dnsreads aca_fileinto the backend’sca_pem. The app reads it throughread_file, which names the key in its error. katana’slower_dnsreads it withstd::fs::readand passes the OS error on as it is.- A subscribe file’s servers are URLs, and
Backend::from_urlsetsca_pem: Nonefor every one. A pinned CA can therefore come only from the config’s[dns], which lowers toSingle. - When a subscribe file has a
[dns]section, the config’s own[dns]is ignored and never lowered. If the config’s[dns]differs fromDnsConfig::default(),app/src/subscribe.rs→applylogs a warning (targetetemenanki_app::subscribe) naming the subscribe file by its[subscribe].pathas written, for examplesub.toml has a [dns] section, so the config's [dns] is ignored. - A subscribe
[dns]is the only way the app producesSplit, 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:
[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,}Invariants
Section titled “Invariants”| 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) |
Failure paths and cancellation
Section titled “Failure paths and cancellation”When the setup is built
Section titled “When the setup is built”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 |
When a query is answered
Section titled “When a query is answered”| 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.
Log lines
Section titled “Log lines”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.
Cancellation
Section titled “Cancellation”- A connection that ends drops its
DnsTcpStreamor 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 lastSupervisorhandle 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:
commitcancels 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.
Limits
Section titled “Limits”| 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
RoutedDialerand a real outbound. - None covers the
Splitarm ofbuild_dnsbeyond whatcheckbuilds, or checks thatpreparekeeps a reused setup. - The
NOTIMPanswer and the answer when nothing can be relayed have no test inprotocols/tests/unit/dns/serve.rs.
See Testing for how the suites are laid out.