ts_runtime/magic_dns.rs
1//! MagicDNS responder with a split-DNS / recursive forwarder.
2//!
3//! An in-netstack DNS server bound to `100.100.100.100:53`. It is authoritative for in-tailnet
4//! peer names and control-pushed [`ExtraRecord`][ts_control::ExtraRecord]s, answering `A`/`AAAA`/
5//! `PTR` for those directly — plus, for a peer control has marked with the `dns-subdomain-resolve`
6//! node attribute ([`Node::resolves_subdomains`]), every name *under* that peer's name
7//! ([`DnsView::subdomain_host_for`]). For names it is *not* authoritative for, it brings tsnet-style
8//! split-DNS and recursive resolution:
9//!
10//! - **Split DNS** ([`DnsConfig::routes`]): the longest matching suffix route forwards the query
11//! to one of that route's upstream resolvers. A route with an **empty** upstream list is a
12//! negative route — names under it are `NXDOMAIN` (Go keeps them on the built-in resolver; for
13//! us that means fail-closed unless an overlay/extra record matched first).
14//! - **Recursive** ([`DnsConfig::fallback_resolvers`] / [`DnsConfig::resolvers`]): names matching
15//! no route are forwarded to the fallback resolvers, else the global resolvers.
16//! - **Fail closed**: if no route and no resolver is configured, an unknown name is `NXDOMAIN`.
17//! - **A refusing upstream does not end a forward**: a `REFUSED` or `SERVFAIL` from one upstream is
18//! a *soft* error — the next upstream on the route (or in the fallback list) is tried. A refusal
19//! is relayed to the client only when every upstream refused; a mix of failures is a SERVFAIL
20//! (see [`forward_query`]).
21//!
22//! Anti-leak / IPv6-off posture: upstream forwarding binds `0.0.0.0:0` on the overlay netstack —
23//! UDP for the query, TCP for the hop raced against it, IPv4 only either way — and never opens an
24//! IPv6 socket, nor a host socket. AAAA handling is gated on [`DnsView::enable_ipv6`] (default
25//! off): with the gate OFF an AAAA query for a tailnet/overlay/self name returns NoError with an
26//! empty answer (NODATA) rather than the overlay v6 address — answering a v6 the IPv4-only client
27//! can't route would only create dead connections and a fingerprint. With the gate ON, AAAA is
28//! answered from overlay data (the v6 overlay addr), as historically. AAAA for tailnet names is
29//! never forwarded to a recursive upstream regardless of the gate.
30//!
31//! - MagicDNS disabled (`dns_config == None` or `magic_dns == false`), OR the node does not accept
32//! the tailnet DNS config ([`DnsView::accept_dns`] is `false`, i.e. `--accept-dns` / `CorpDNS`
33//! off) => `REFUSED` for every query (the responder serves nothing, mirroring Go applying an empty
34//! `dns.Config` when `CorpDNS` is off).
35//! - A qtype/class we don't serve authoritatively (anything but IN-class A/AAAA/PTR — TXT, SRV, MX,
36//! HTTPS/SVCB, a CHAOS-class query, …) => NODATA (empty NOERROR) for a tailnet-authoritative name,
37//! forwarded verbatim to upstream for an off-tailnet name — exactly like Go's resolver, NOT
38//! `REFUSED` (a stub reads REFUSED as "won't serve me" and abandons the resolver). Tailnet reverse
39//! zones (CGNAT `in-addr.arpa` / any `ip6.arpa`) still fail closed to NXDOMAIN for every qtype
40//! (never forwarded — anti-leak).
41//! - A **negative** answer this node is authoritative for — an NXDOMAIN for a name inside a zone we
42//! serve (a tailnet search domain, a negative split-DNS route, or the CGNAT reverse zone), or a
43//! NODATA for such a name — carries that zone's `SOA` in the authority section, advertising a
44//! 10-second negative-caching bound (RFC 2308). Without one, macOS `mDNSResponder` keeps an
45//! SOA-less negative answer on its own schedule, so a name queried shortly *before* a node was
46//! renamed to it stays unresolvable until something flushes the cache. Positive answers carry a
47//! 5-second TTL for the same reason in the other direction. `SERVFAIL`, `REFUSED` and the blanket
48//! `ip6.arpa` refusal claim no zone and carry no SOA.
49//! - Malformed query => dropped (no response).
50//! - A reply larger than the UDP payload size the query advertised — its EDNS(0) OPT record, or 512
51//! bytes when it carried none or carried one this node will not act on (RFC 1035) — comes back
52//! with the `TC` (truncated) bit set and its body intact, so the stub resolver knows to retry over
53//! TCP ([`check_response_size_and_set_tc`]). This applies to forwarded replies, where the query is
54//! relayed verbatim and so this is what catches an upstream that ignores the size its requestor
55//! asked for, and equally to answers this node composes itself. The retry that bit asks for is served
56//! by the `dns_over_tcp` server (TUN mode), which reaches the same [`decide`] through
57//! the same view — and which is never handed a `TC` bit for size, since a TCP client has no
58//! datagram to overflow (see [`ClientTransport`]).
59//! - The hop to the **upstream** resolver makes the same retry on the client's behalf: a forwarded
60//! answer that comes back with `TC` set is re-asked of the same resolver over TCP, over the same
61//! overlay netstack channel the UDP hop uses and never a host socket ([`ask_upstream`]). Without
62//! it the client's own TCP retry lands back on a UDP hop and is answered with the same truncated
63//! message, and a name whose answer does not fit a datagram simply does not resolve through this
64//! node. The TCP hop is also **raced** against a slow or failed UDP hop, as Go's forwarder races
65//! them: it joins after [`UDP_RACE_TIMEOUT`] (at once for a TCP client, or when the UDP hop has
66//! already failed), so a resolver whose UDP path drops but whose TCP path answers still resolves.
67//! Control's `dns-forwarder-disable-tcp-retries` node attribute is the *off* switch for all of it
68//! ([`DnsView::upstream_tcp_retry`]); the TCP hop is on by default.
69
70use std::{
71 net::{IpAddr, Ipv4Addr, SocketAddr},
72 sync::Arc,
73 time::Duration,
74};
75
76use kameo::{
77 actor::ActorRef,
78 message::{Context, Message},
79};
80use netstack::{CreateSocket, netcore::Channel};
81use tokio::{
82 io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
83 sync::{Semaphore, watch},
84 task::JoinSet,
85 time::timeout,
86};
87use ts_control::{DnsConfig, DnsResolver, Node};
88use ts_dns_wire::{Name, QType, RData, Rcode, SoaZone, decode_query, encode_response};
89
90use crate::{
91 Error,
92 env::Env,
93 peer_tracker::{PeerDb, PeerState},
94};
95
96/// How long to wait for an upstream resolver to answer a forwarded query before giving up.
97const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(5);
98/// Cap on concurrent in-flight forwarded queries on the local `100.100.100.100:53` responder.
99///
100/// Each forward is spawned onto a task that holds an overlay UDP socket until the upstream answers
101/// or [`UPSTREAM_TIMEOUT`] elapses. Without a cap, a local/tailnet client spraying distinct
102/// forwardable names opens unbounded concurrent overlay sockets + tasks (a resource-exhaustion DoS
103/// on a slow/black-holed upstream, since each lingers for the full timeout). Bound it the same way
104/// the peerAPI DoH server bounds its request handlers ([`crate::peerapi`]'s `MAX_INFLIGHT`): acquire
105/// a permit before spawning and drop the query fail-closed when saturated. A dropped DNS query is a
106/// benign outcome — the stub resolver simply retries or times out — and Go's resolver likewise
107/// bounds outstanding forwards rather than spawning without limit.
108const MAX_INFLIGHT_FORWARDS: usize = 512;
109/// Cap on how much of a forwarded upstream response we relay back to the stub resolver (a single
110/// UDP datagram).
111///
112/// This is Go's `maxResponseBytes` — `const maxResponseBytes = 4095`, defined in
113/// net/dns/resolver/tsdns.go @ 9ea7cba44591e0cd840c6c94d23274dd222059bf and used by the forwarder.
114/// The odd-looking 4095 is deliberate upstream: `sendUDP` reads into a `maxResponseBytes+1` buffer
115/// precisely so that a 4096-byte read is *detectable* as "the answer did not fit", and then cuts the
116/// reply back to 4095 and sets `TC`. A 4096-byte answer is a truncated answer everywhere else in a
117/// tailnet, so it has to be one here too: relaying it whole with `TC` clear is a message a Go peer
118/// would have handed its stub resolver as truncated.
119///
120/// *Where the bound applies differs from Go*: here it is not a read bound and it does not bound
121/// memory. [`forward_query`] reads with `recv_from_bytes`, which issues `Recv { max_len: None }`, so
122/// the netstack has already copied the whole queued datagram out before [`cap_response`] sees it.
123/// What bounds the read is the netstack UDP socket's receive ring —
124/// `netcore::Config::udp_buffer_size`, 4 KiB by default and not overridden by `ts_runtime` — and
125/// smoltcp drops a datagram larger than that ring at enqueue rather than delivering a chopped one.
126/// That ring is 4096 and this cap is 4095, so the ring plays exactly the part Go's `+1` byte plays:
127/// the one datagram size it can deliver and this cap cannot pass is the full-ring 4096-byte answer,
128/// which [`cap_response`] then chops to 4095 and marks `TC` (pinned by
129/// `full_ring_datagram_is_chopped_and_marked_truncated`).
130///
131/// The client's query is forwarded verbatim, so a client advertising a large EDNS UDP size can
132/// elicit a legitimately large (1300–4095 byte) UDP answer (big TXT sets, DNSSEC, many-record
133/// round-robins). Capping at the old 1232 truncated those and set TC, forcing a TCP retry — which
134/// nothing served at the time, so the large answer became unreachable. 4095 relays them intact.
135///
136/// It is a **datagram** bound, and since [`ask_upstream`] gained its TCP retry it is applied as one:
137/// an answer that came over the TCP hop and is going back to a TCP client never crossed a datagram
138/// and is not cut here (see [`cap_response`]). Every other combination is, because a UDP hop could
139/// not have delivered more than the receive ring anyway and a UDP client cannot be answered with
140/// more than one datagram. So an answer over 4095 bytes still reaches a UDP stub resolver as a
141/// `TC`-marked 4095 bytes — which is the retry RFC 1035 §4.2.1 asks it to make, and `dns_over_tcp`
142/// answers that retry whole.
143const MAX_UPSTREAM_RESPONSE: usize = 4095;
144
145/// The MagicDNS service IP. The netstack interface owns this address, so a `udp_bind` here
146/// receives the tailnet's DNS traffic.
147const MAGIC_DNS_IP: Ipv4Addr = Ipv4Addr::new(100, 100, 100, 100);
148/// The DNS service port.
149const MAGIC_DNS_PORT: u16 = 53;
150
151/// The latest view the answer loop resolves queries against.
152///
153/// Updated by the actor's message handlers (from control `StateUpdate` and peer `PeerState`
154/// updates) and read fresh by the answer loop for every packet.
155#[derive(Clone, Default)]
156pub(crate) struct DnsView {
157 /// The DNS configuration. `magic_dns == false` (the default) means serve nothing.
158 pub(crate) cfg: DnsConfig,
159 /// The current peer database, if we've seen a peer update.
160 pub(crate) peers: Option<Arc<PeerDb>>,
161 /// This node, if we've seen a self-node update.
162 pub(crate) self_node: Option<Node>,
163 /// The peerAPI DoH socket address of the currently-selected exit node, if one is active and can
164 /// proxy DNS ([`Node::peerapi_doh_addr`]). When set, the MagicDNS *client* serve loop delegates
165 /// recursive resolution to this address over the overlay instead of forwarding to the locally
166 /// configured upstream resolvers — so recursive DNS egresses from the exit node, not this host.
167 ///
168 /// Only consumed by the local MagicDNS responder's serve loop (the client side). The peerAPI
169 /// DoH *server* shares this same view but ignores this field: an exit-node DNS proxy resolves
170 /// recursively itself (gated by `forward_exit_egress`), it never re-delegates to its own exit
171 /// node. `None` means no active exit node / no DoH delegation — recursion stays local.
172 pub(crate) exit_doh: Option<SocketAddr>,
173 /// Whether IPv6 is enabled on the tailnet overlay (from [`Env::enable_ipv6`], default `false`).
174 ///
175 /// Governs the AAAA answer path only: with the gate OFF (default) an AAAA query for a
176 /// tailnet/overlay/self name is answered NoError-with-empty-answer (NODATA) instead of the
177 /// overlay v6 address; with it ON, AAAA is answered from overlay data as historically. Set once
178 /// from the runtime `Env` when the actor starts; never changes for the life of the runtime.
179 pub(crate) enable_ipv6: bool,
180 /// Whether the tailnet's DNS configuration is accepted (`--accept-dns` / `CorpDNS`, from
181 /// [`Env::accept_dns`]). When `false`, [`decide`] refuses every query (the responder serves
182 /// nothing), mirroring Go applying an empty `dns.Config` when `CorpDNS` is off — so a node can
183 /// join for connectivity without taking over DNS.
184 ///
185 /// Unlike [`enable_ipv6`](DnsView::enable_ipv6) (snapshotted once at actor spawn), this is
186 /// runtime-settable via `Device::set_accept_dns`, so it is re-read from the live
187 /// [`Env::accept_dns`] cell on **every** view rebuild (the `StateUpdate` and `PeerState`
188 /// handlers), not just at spawn — otherwise a runtime toggle would never reach the served view.
189 pub(crate) accept_dns: bool,
190}
191
192impl DnsView {
193 /// Find the node (peer or self) that answers to `name`, case/dot-insensitively.
194 fn node_by_name(&self, name: &str) -> Option<Node> {
195 if let Some(node) = self
196 .peers
197 .as_ref()
198 .and_then(|p| p.get(&name).map(|(_, n)| n.clone()))
199 {
200 return Some(node);
201 }
202
203 self.self_node
204 .as_ref()
205 .filter(|n| n.matches_name(name))
206 .cloned()
207 }
208
209 /// Find the node a **parent** of `canon` names, when that node is a *subdomain host* — a node
210 /// control has set the `dns-subdomain-resolve` attribute on
211 /// ([`Node::resolves_subdomains`]), meaning every name under it resolves to its addresses.
212 ///
213 /// Mirrors the miss path of Go's resolver (`net/dns/resolver/tsdns.go`): a name that matches no
214 /// host walks its parents (`util/dnsname`'s `Parent`) and answers from the first parent that is
215 /// a subdomain host. The walk is over *every* parent, not one level: for a node `machine`, both
216 /// `my.machine` and `be.my.machine` resolve to it.
217 ///
218 /// Two bounds keep the walk from becoming a wildcard:
219 ///
220 /// - It stops at a **tailnet search domain**. `user.ts.net` is the zone apex, not a host under
221 /// it, so the walk never climbs past it into names this node is not authoritative for.
222 /// - A candidate parent must be **fully qualified** (at least two labels) and is matched
223 /// *exactly*, with no search-domain qualification. Unlike [`DnsView::resolve_addr`]'s exact
224 /// lookup, the walk must not expand a short name against the search list: the peer-name index
225 /// also holds bare hostnames, so a peer named after a public suffix (`com`, `dev`) carrying
226 /// the attribute would otherwise swallow every name under that suffix — a hijack Go cannot
227 /// perform, because its resolver does no search-list expansion at all (the client stub does).
228 /// A stub resolver qualifies a short name against the search list before asking, so the
229 /// fully-qualified form is what arrives here anyway.
230 fn subdomain_host_for(&self, canon: &str) -> Option<Node> {
231 let mut parent = canon;
232 while let Some((_, rest)) = parent.split_once('.') {
233 parent = rest;
234 // A bare label is never a candidate (see the doc comment): nothing is left to walk.
235 if !parent.contains('.') {
236 return None;
237 }
238 // The tailnet zone apex itself: stop rather than climb out of the zone we serve.
239 if self.cfg.search_domains.iter().any(|zone| zone == parent) {
240 return None;
241 }
242 if let Some(node) = self.node_by_name(parent)
243 && node.resolves_subdomains()
244 {
245 return Some(node);
246 }
247 }
248 None
249 }
250
251 /// Resolve `canon` to an answer address of the requested family. A tailnet peer/self match
252 /// wins first — tried as written and then qualified by each tailnet search domain (so a
253 /// short/partially-qualified name like `host` or `host.user` still resolves to
254 /// `host.user.ts.net`). Failing that, a control-pushed [`ExtraRecord`] of the matching family
255 /// answers, matched as a fully-qualified name only (no search-domain expansion — like Go tsnet,
256 /// ExtraRecords are authoritative FQDN entries, not subject to client search-list qualification).
257 /// Only when nothing matched the name *exactly* does the subdomain-host parent walk run
258 /// ([`DnsView::subdomain_host_for`]) — so an exact name always beats a parent match, as it does
259 /// upstream, where the parent walk is the lookup-miss path.
260 /// Still fail-closed: only ever resolves to a known tailnet peer/self or an explicitly
261 /// control-pushed static record — never anything else.
262 fn resolve_addr(&self, canon: &str, want_v4: bool) -> Option<IpAddr> {
263 let addr_of = |node: Node| -> IpAddr {
264 if want_v4 {
265 IpAddr::from(node.tailnet_address.ipv4.addr())
266 } else {
267 IpAddr::from(node.tailnet_address.ipv6.addr())
268 }
269 };
270
271 if let Some(node) = self.node_by_name(canon) {
272 return Some(addr_of(node));
273 }
274 for suffix in &self.cfg.search_domains {
275 if let Some(node) = self.node_by_name(&format!("{canon}.{suffix}")) {
276 return Some(addr_of(node));
277 }
278 }
279
280 // Control-pushed static records match the fully-qualified query name only.
281 let mut named_by_extra_record = false;
282 for rec in &self.cfg.extra_records {
283 if rec.name != canon {
284 continue;
285 }
286 named_by_extra_record = true;
287 if matches!(
288 (rec.addr, want_v4),
289 (IpAddr::V4(_), true) | (IpAddr::V6(_), false)
290 ) {
291 return Some(rec.addr);
292 }
293 }
294 // An extra record for this exact name but of the other family means the name *exists* and
295 // simply holds no address of the queried type — Go's lookup found it, so the parent walk
296 // (its miss path) does not run and the answer stays NODATA.
297 if named_by_extra_record {
298 return None;
299 }
300
301 // Nothing answers this name exactly: fall back to a parent that resolves its subdomains.
302 self.subdomain_host_for(canon).map(addr_of)
303 }
304
305 /// Find the node (peer or self) that owns the tailnet IP `ip`.
306 fn node_by_ip(&self, ip: IpAddr) -> Option<Node> {
307 if let Some(node) = self
308 .peers
309 .as_ref()
310 .and_then(|p| p.get(&ip).map(|(_, n)| n.clone()))
311 {
312 return Some(node);
313 }
314
315 self.self_node
316 .as_ref()
317 .filter(|n| {
318 IpAddr::from(n.tailnet_address.ipv4.addr()) == ip
319 || IpAddr::from(n.tailnet_address.ipv6.addr()) == ip
320 })
321 .cloned()
322 }
323
324 /// Decide how to resolve a non-overlay `name` against the split-DNS routes and recursive
325 /// resolvers, returning the upstreams to forward to.
326 ///
327 /// Longest-suffix wins among [`DnsConfig::routes`]: a route's suffix matches `name` if `name`
328 /// equals it or ends with `.suffix`. A matched route with a non-empty upstream list forwards
329 /// there; a matched route with an **empty** list is a negative route ([`Upstreams::Block`] =>
330 /// NXDOMAIN). With no route match, [`DnsConfig::fallback_resolvers`] (preferred) or
331 /// [`DnsConfig::resolvers`] resolve recursively; if neither is configured we stay fail-closed
332 /// ([`Upstreams::None`] => NXDOMAIN).
333 fn route_for(&self, name: &str) -> Upstreams<'_> {
334 let mut best: Option<(&str, &Vec<DnsResolver>)> = None;
335 for (suffix, upstreams) in &self.cfg.routes {
336 if suffix_matches(name, suffix) && best.is_none_or(|(b, _)| suffix.len() > b.len()) {
337 best = Some((suffix.as_str(), upstreams));
338 }
339 }
340
341 if let Some((_, upstreams)) = best {
342 return if upstreams.is_empty() {
343 Upstreams::Block
344 } else {
345 // A deliberately-configured split-DNS route: not eligible for exit-node DoH
346 // delegation — these upstreams (e.g. an internal resolver reachable over a subnet
347 // route) must keep receiving the query directly.
348 Upstreams::Route(upstreams)
349 };
350 }
351
352 if !self.cfg.fallback_resolvers.is_empty() {
353 return Upstreams::Recursive(&self.cfg.fallback_resolvers);
354 }
355 if !self.cfg.resolvers.is_empty() {
356 return Upstreams::Recursive(&self.cfg.resolvers);
357 }
358 Upstreams::None
359 }
360
361 /// Whether the forwarder may use TCP to an upstream resolver — to re-ask a truncated answer, or
362 /// to race a slow or failed UDP hop ([`ask_upstream`]).
363 ///
364 /// On unless control's `dns-forwarder-disable-tcp-retries` node attribute is set on the **self**
365 /// node ([`Node::disable_dns_forwarder_tcp_retries`]) — the attribute is the retry's *off*
366 /// switch, not its on switch, so a node with no self-node update yet (and every tailnet that
367 /// never set it) retries. Go reads the same knob at the same point:
368 /// `skipTCP := skipTCPRetry() || (f.controlKnobs != nil &&
369 /// f.controlKnobs.DisableDNSForwarderTCPRetries.Load())`, net/dns/resolver/forwarder.go:677 @
370 /// `023255e8a27ec9f6a21d24e3eda21c052ff72af3`.
371 ///
372 /// Go's other disjunct, `skipTCPRetry()`, is a process-level environment knob and has no
373 /// counterpart here: this tree carries no `envknob` equivalent, and the deployment switch a
374 /// tailnet operator actually reaches for is the node attribute.
375 pub(crate) fn upstream_tcp_retry(&self) -> TcpRetry {
376 match &self.self_node {
377 Some(node) if node.disable_dns_forwarder_tcp_retries() => TcpRetry::Disabled,
378 _ => TcpRetry::Enabled,
379 }
380 }
381}
382
383/// Whether the forwarder may ask an upstream over **TCP** at all — to re-ask a truncated answer, or
384/// to race a slow or failed UDP hop. Decided per query from the current view
385/// ([`DnsView::upstream_tcp_retry`]) and carried to [`ask_upstream`], which is the only thing that
386/// reads it.
387#[derive(Clone, Copy, Debug, PartialEq, Eq)]
388pub(crate) enum TcpRetry {
389 /// Race the TCP hop against the UDP hop, and retry a `TC`-marked UDP answer over TCP, to the
390 /// same resolver. The default.
391 Enabled,
392 /// UDP only, on every arm: relay whatever the UDP hop brings back, truncated or not, and never
393 /// open a TCP connection. Control's `dns-forwarder-disable-tcp-retries` attribute.
394 Disabled,
395}
396
397/// The upstreams a non-overlay query should be forwarded to (or why it should not be forwarded).
398enum Upstreams<'a> {
399 /// A split-DNS route matched: forward to these route-specific upstreams (never DoH-delegated).
400 Route(&'a [DnsResolver]),
401 /// No route matched: forward to these recursive (fallback/global) resolvers. Eligible for
402 /// exit-node DoH delegation in the client serve loop.
403 Recursive(&'a [DnsResolver]),
404 /// A negative split-DNS route matched: do not resolve (NXDOMAIN). The route's suffix is a zone
405 /// this node is authoritative for — Go's `localDomains` is exactly the set of routes configured
406 /// with no resolvers — so [`authoritative_zone_for`] finds it again when naming the negative
407 /// answer's SOA zone.
408 Block,
409 /// No route and no resolver configured: fail closed (NXDOMAIN).
410 None,
411}
412
413/// What the (sync) decision step concluded for a query: either a complete response to send back,
414/// or a request to forward the original query to an upstream resolver.
415pub(crate) enum Decision {
416 /// A fully-formed response is ready to send.
417 Reply(Vec<u8>),
418 /// Forward the original query datagram to one of these upstream UDP resolvers; on success
419 /// relay the upstream answer, on failure/timeout answer with the prebuilt `servfail` buffer
420 /// (an off-tailnet name we failed to forward is a soft failure, not a cacheable non-existence —
421 /// Go forwarder.go:1297-1307).
422 Forward {
423 /// UDP upstreams to try, in order.
424 upstreams: Vec<SocketAddr>,
425 /// The original query bytes to forward verbatim.
426 query: Vec<u8>,
427 /// Fallback SERVFAIL response if every upstream fails or times out.
428 servfail: Vec<u8>,
429 /// Whether this is a *recursive* (catch-all fallback/global resolver) forward, as opposed
430 /// to a deliberately-configured split-DNS route. Only recursive forwards are eligible for
431 /// exit-node DoH delegation in the client serve loop (see [`DnsView::exit_doh`]); split-DNS
432 /// routes always stay on their configured upstreams (typically subnet-reachable internal
433 /// resolvers). The peerAPI DoH *server* ignores this flag entirely.
434 recursive: bool,
435 },
436}
437
438/// Whether `name` is `suffix` or sits under it at a label boundary: `"a.corp"` matches `"corp"`,
439/// `"acorp"` does not. An **empty** suffix never matches (defense-in-depth: an empty suffix would
440/// otherwise make `ends_with("")` match every name and either over-route or treat everything as a
441/// tailnet name — both leak-prone).
442fn suffix_matches(name: &str, suffix: &str) -> bool {
443 if suffix.is_empty() {
444 return false;
445 }
446 name == suffix
447 || (name.len() > suffix.len()
448 && name.ends_with(suffix)
449 && name.as_bytes()[name.len() - suffix.len() - 1] == b'.')
450}
451
452/// Returns `true` if `name` falls under one of the tailnet search domains. Such names are
453/// authoritative MagicDNS names and are NEVER forwarded to an upstream resolver — anti-leak: a
454/// tailnet name (and the fact that it was queried) must not escape to a third-party resolver.
455fn is_tailnet_name(view: &DnsView, name: &str) -> bool {
456 view.cfg
457 .search_domains
458 .iter()
459 .any(|suffix| suffix_matches(name, suffix))
460}
461
462/// Whether `name` is an IPv6 reverse-DNS (`PTR`) name (ends in `ip6.arpa`). This fork is IPv4-only
463/// on the tailnet; an IPv6 reverse lookup must NEVER be forwarded to a third-party resolver
464/// (anti-leak: it would reveal that a tailnet v6 address — e.g. a ULA `fd7a:…` — was probed). All
465/// such queries fail closed to NXDOMAIN.
466fn is_ip6_arpa(name: &str) -> bool {
467 suffix_matches(name, "ip6.arpa")
468}
469
470/// Whether `ip` is in the Tailscale CGNAT range `100.64.0.0/10` (RFC 6598, the tailnet IPv4 space).
471/// Reverse (`PTR`) queries for these addresses are authoritative to MagicDNS: if no peer owns the
472/// IP we fail closed to NXDOMAIN rather than forwarding the probe to a third-party resolver.
473fn is_tailnet_cgnat(ip: Ipv4Addr) -> bool {
474 let o = ip.octets();
475 o[0] == 100 && (64..=127).contains(&o[1])
476}
477
478/// The zone this node is authoritative for that contains `canon`, or `None` when it is not
479/// authoritative for the name.
480///
481/// Mirrors Go `net/dns/resolver/tsdns.go` `authoritativeZoneFor`, which scans `Resolver.localDomains`.
482/// Go's `localDomains` is exactly the set of control-pushed routes with **no** resolvers
483/// (`net/dns/manager.go` `compileConfig`), so the equivalent set here is the union of:
484///
485/// - the tailnet search domains — what [`is_tailnet_name`] tests, and the zone a tailnet-suffix
486/// NXDOMAIN belongs to;
487/// - the negative split-DNS routes (a route with an empty upstream list), the literal shape of
488/// Go's `localDomains`;
489/// - the CGNAT reverse zone `<b>.100.in-addr.arpa` covering a `100.64.0.0/10` reverse name.
490/// Synthesized rather than read from the routes: this fork's reverse guard is structural
491/// ([`is_tailnet_cgnat`]) and holds whether or not control pushed the matching route, and the
492/// zone it names is the same per-/16 chunk real tailscaled advertises.
493///
494/// `ip6.arpa` is deliberately absent. This fork NXDOMAINs *every* `ip6.arpa` name as an anti-leak
495/// measure ([`is_ip6_arpa`]) rather than because it serves that zone, and an SOA naming `ip6.arpa`
496/// would claim authority over the whole IPv6 reverse tree — a claim we do not have and one that
497/// would have a client negative-cache far more than this node answers for.
498///
499/// The longest match wins. Go returns the first match from an unordered slice; longest gives the
500/// same answer whenever the zones nest (the usual case) and is a defensible tie-break when they
501/// do not.
502fn authoritative_zone_for(view: &DnsView, name: &Name, canon: &str) -> Option<String> {
503 if let Some(octets) = name.ptr_to_ipv4() {
504 let v4: Ipv4Addr = octets.into();
505 if is_tailnet_cgnat(v4) {
506 return Some(format!("{}.100.in-addr.arpa", v4.octets()[1]));
507 }
508 }
509
510 view.cfg
511 .search_domains
512 .iter()
513 .map(String::as_str)
514 .chain(
515 view.cfg
516 .routes
517 .iter()
518 .filter(|(_, upstreams)| upstreams.is_empty())
519 .map(|(suffix, _)| suffix.as_str()),
520 )
521 .filter(|zone| suffix_matches(canon, zone))
522 .max_by_key(|zone| zone.len())
523 .map(str::to_owned)
524}
525
526/// The SOA record to attach to an authoritative **negative** answer (NXDOMAIN, or NODATA for a
527/// name we serve), or `None` when this node is not authoritative for a zone containing the name.
528///
529/// Without it, a downstream cache decides for itself how long to remember the nonexistence: macOS
530/// `mDNSResponder` holds an SOA-less negative answer for a long time, so a name queried shortly
531/// *before* a node was renamed to it keeps failing until something flushes the cache. The SOA
532/// bounds that at 10 seconds (RFC 2308), which is what Go's resolver advertises.
533fn soa_for(view: &DnsView, name: &Name, canon: &str) -> Option<SoaZone> {
534 let zone = authoritative_zone_for(view, name, canon)?;
535 Some(SoaZone {
536 zone: Name(zone.split('.').map(str::to_owned).collect()),
537 serial: soa_serial(),
538 })
539}
540
541/// The SOA SERIAL to publish: the response time in unix seconds.
542///
543/// A serial is meant to change only when the zone data does, but nothing consumes ours — this node
544/// has no secondaries and serves no zone transfers — so Go uses the current time and so do we. It
545/// is monotonic, cheap, and fits in a `u32` until 2106. A clock before the epoch yields 0 rather
546/// than panicking; the value carries no meaning either way.
547fn soa_serial() -> u32 {
548 std::time::SystemTime::now()
549 .duration_since(std::time::UNIX_EPOCH)
550 .map_or(0, |since| since.as_secs() as u32)
551}
552
553/// Decide what to do with a single DNS query against `view`: either a complete response is ready
554/// ([`Decision::Reply`]), the query should be forwarded to upstream resolvers
555/// ([`Decision::Forward`]), or the packet should be dropped without answering (`None`).
556///
557/// Factored out of the socket loop so it can be unit-tested without a netstack: it does no I/O and
558/// reads no state but `view` and the wall clock (the SOA SERIAL of a negative answer, which nothing
559/// consumes — see [`soa_serial`]). It never panics and fails closed: an unknown, unroutable, or
560/// tailnet-suffix name resolves to NXDOMAIN rather than leaking to an upstream resolver.
561pub(crate) fn decide(view: &DnsView, buf: &[u8]) -> Option<Decision> {
562 // Malformed / non-query input is dropped: we never answer something we can't parse.
563 let query = decode_query(buf).ok()?;
564 let q = &query.question;
565 let id = query.id;
566 // Echo the query's RD bit (and set RA when set) on the response — Go derives the response header
567 // from the query header.
568 let rd = query.recursion_desired;
569
570 let reply = |rcode, answers: &[RData]| {
571 Decision::Reply(encode_response(id, q, rd, rcode, answers, None))
572 };
573 // A negative answer (NXDOMAIN, or NODATA) for a name inside a zone we serve carries that zone's
574 // SOA in the authority section, which bounds how long a downstream resolver may cache the
575 // nonexistence (RFC 2308). `soa_for` returns `None` when we are not authoritative for the name,
576 // in which case this is exactly `reply`.
577 let reply_negative = |rcode, canon: &str| {
578 Decision::Reply(encode_response(
579 id,
580 q,
581 rd,
582 rcode,
583 &[],
584 soa_for(view, &q.name, canon).as_ref(),
585 ))
586 };
587
588 // Fail closed: MagicDNS off, or the node doesn't accept the tailnet's DNS config
589 // (`--accept-dns` / `CorpDNS` is false) => serve nothing. The `accept_dns` gate mirrors Go
590 // applying an empty `dns.Config` when `CorpDNS` is off: the node ignores the control-pushed DNS
591 // config and refuses every query. This one read site covers the netstack responder, the peerAPI
592 // DoH server that shares the view, and (via `tun_actor::plan_intercept`) the TUN query path.
593 if !view.cfg.magic_dns || !view.accept_dns {
594 return Some(reply(Rcode::Refused, &[]));
595 }
596
597 let canon = q.name.to_canon();
598
599 // We only serve the internet (IN) class authoritatively. A non-IN class (CHAOS, HESIOD, the
600 // ANY/255 class, ...) is NOT refused outright: Go's local resolver does no class check and
601 // forwards such a query like any other name. Treat it as an unsupported authoritative type —
602 // NODATA for a tailnet name, forward for an off-tailnet name — so a `CH TXT version.bind`
603 // diagnostic or a `qclass=ANY` probe reaches upstream instead of getting REFUSED.
604 const CLASS_IN: u16 = 1;
605 if q.qclass != CLASS_IN {
606 return Some(forward_or_nodata(view, &canon, buf, id, q, rd));
607 }
608
609 Some(match &q.qtype {
610 QType::A => match view.resolve_addr(&canon, true) {
611 Some(IpAddr::V4(v4)) => reply(Rcode::NoError, &[RData::A(v4.octets())]),
612 // No overlay/extra-record answer: try split-DNS / recursive upstreams.
613 _ => forward_or_nxdomain(view, &canon, buf, id, q, rd),
614 },
615 QType::Aaaa => match view.resolve_addr(&canon, false) {
616 // A tailnet/overlay/self (or extra-record) AAAA match. Gate on IPv6: with IPv6 OFF
617 // (default) the client is IPv4-only, so answering with the overlay v6 address would
618 // only hand out an unroutable address — dead connections plus a fingerprint. Return
619 // NoError with an empty answer (NODATA) instead. With the gate ON, answer from overlay
620 // data as historically. We never forward this name to a recursive upstream either way:
621 // a positive overlay match is authoritative.
622 Some(IpAddr::V6(v6)) if view.enable_ipv6 => {
623 reply(Rcode::NoError, &[RData::Aaaa(v6.octets())])
624 }
625 // NODATA: the name exists but we hold no address of the queried family for it, so it
626 // takes the SOA — Go sets `SOAZone` on exactly this case (`rcode == RCodeSuccess &&
627 // !ip.IsValid()` for an A/AAAA/ALL question).
628 Some(IpAddr::V6(_)) => reply_negative(Rcode::NoError, &canon),
629 // No overlay/extra-record answer: split-DNS / recursive upstreams (off-tailnet names);
630 // tailnet names fail closed to NXDOMAIN inside `forward_or_nxdomain`.
631 _ => forward_or_nxdomain(view, &canon, buf, id, q, rd),
632 },
633 QType::Ptr => match q.name.ptr_to_ipv4() {
634 Some(octets) => {
635 let v4: Ipv4Addr = octets.into();
636 let ip = IpAddr::V4(v4);
637 match view.node_by_ip(ip) {
638 Some(node) => {
639 let fqdn = node.fqdn(false);
640 let labels: Vec<String> = fqdn.split('.').map(str::to_owned).collect();
641 reply(Rcode::NoError, &[RData::Ptr(Name(labels))])
642 }
643 // Anti-leak: a reverse query for an IP in the tailnet CGNAT range
644 // (100.64.0.0/10) that misses the peer set is authoritative-but-unknown; fail
645 // closed to NXDOMAIN rather than leaking the probed tailnet IP upstream. Only
646 // genuinely off-tailnet reverse queries are forwarded.
647 None if is_tailnet_cgnat(v4) => reply_negative(Rcode::NxDomain, &canon),
648 None => forward_or_nxdomain(view, &canon, buf, id, q, rd),
649 }
650 }
651 // Anti-leak / IPv4-only-tailnet: an IPv6 reverse (`ip6.arpa`) PTR must never be
652 // forwarded — relaying it would reveal that a tailnet v6 address (e.g. a ULA `fd7a:…`)
653 // was probed. Fail closed to NXDOMAIN, exactly like the IPv4 CGNAT guard above. No SOA:
654 // this blanket refusal is anti-leak, not a claim to serve `ip6.arpa` (see
655 // [`authoritative_zone_for`]).
656 None if is_ip6_arpa(&canon) => reply(Rcode::NxDomain, &[]),
657 None => forward_or_nxdomain(view, &canon, buf, id, q, rd),
658 },
659 // Anything else (TXT, SRV, MX, HTTPS/SVCB, CNAME, ...): we hold no authoritative record of
660 // that type, so — like Go's resolver — forward it to upstream for an off-tailnet name and
661 // return NODATA (empty NOERROR) for a tailnet-authoritative name. NOT REFUSED: a stub reads
662 // REFUSED as "this server won't serve me" and abandons the resolver, which would break
663 // ordinary client lookups (notably HTTPS/SVCB type 65, issued routinely by browsers for
664 // HTTP/3 + ECH) for the same off-tailnet names whose A/AAAA already forward.
665 QType::Other(_) => forward_or_nodata(view, &canon, buf, id, q, rd),
666 })
667}
668
669/// For a name with no overlay answer, consult the split-DNS routes + recursive resolvers and
670/// either forward (to UDP upstreams), answer authoritatively absent (NXDOMAIN), or fail soft
671/// (SERVFAIL) when an off-tailnet name simply can't be forwarded.
672///
673/// Rcode parity with Go's resolver (`net/dns/resolver/tsdns.go` resolution order + `forwarder.go`):
674/// - A **tailnet-authoritative** name (search-domain suffix) or a **negative split-DNS route**
675/// (`Upstreams::Block` — a route configured with no resolvers, which Go answers authoritatively
676/// from Hosts, so an unmatched name under it is authoritatively absent) → **NXDOMAIN**.
677/// - An **off-tailnet** name we cannot forward — no route and no resolver configured
678/// (`Upstreams::None`), or a route whose resolvers are all filtered out (IPv6-only under the
679/// IPv4-only egress) → **SERVFAIL**, matching Go forwarder.go:1207 ("no upstream resolvers set,
680/// returning SERVFAIL"). A cacheable NXDOMAIN on a transient/structural inability to forward would
681/// make a downstream stub cache the *non-existence* of a real name; SERVFAIL is a soft failure the
682/// stub retries.
683///
684/// Anti-leak: a tailnet-suffix name is authoritative and is never forwarded — neither the name nor
685/// the query leaks to a third-party resolver. (The CGNAT `in-addr.arpa` / `ip6.arpa` reverse-zone
686/// NXDOMAIN guards live in the PTR arm of [`decide`] and are likewise unaffected.)
687fn forward_or_nxdomain(
688 view: &DnsView,
689 canon: &str,
690 buf: &[u8],
691 id: u16,
692 q: &ts_dns_wire::Question,
693 rd: bool,
694) -> Decision {
695 // NXDOMAIN for authoritative-absent names; SERVFAIL for an off-tailnet name we can't forward.
696 // An authoritative NXDOMAIN carries the zone's SOA so a downstream cache bounds how long it
697 // remembers the nonexistence (RFC 2308); a SERVFAIL never does — it asserts nothing to cache,
698 // and we are not authoritative for the name we failed to forward.
699 let nxdomain = |canon: &str| {
700 encode_response(
701 id,
702 q,
703 rd,
704 Rcode::NxDomain,
705 &[],
706 soa_for(view, &q.name, canon).as_ref(),
707 )
708 };
709 let servfail = servfail_for_query(buf, id, q);
710
711 if is_tailnet_name(view, canon) {
712 return Decision::Reply(nxdomain(canon));
713 }
714
715 let (resolvers, recursive) = match view.route_for(canon) {
716 Upstreams::Route(resolvers) => (resolvers, false),
717 Upstreams::Recursive(resolvers) => (resolvers, true),
718 // A negative split-DNS route is authoritative-absent (Go answers it from Hosts): NXDOMAIN.
719 // Go's `localDomains` *is* this route set, so the route's own suffix names the zone.
720 Upstreams::Block => return Decision::Reply(nxdomain(canon)),
721 // No route and no resolver: an off-tailnet name we have nowhere to forward — SERVFAIL, not
722 // a cacheable non-existence (Go forwarder.go:1207).
723 Upstreams::None => return Decision::Reply(servfail),
724 };
725
726 let upstreams: Vec<SocketAddr> = resolvers
727 .iter()
728 .map(DnsResolver::udp_addr)
729 // Anti-leak / IPv6-off: only forward over IPv4 upstreams; never open a v6 socket.
730 .filter(SocketAddr::is_ipv4)
731 .collect();
732 if upstreams.is_empty() {
733 // We had a route but every resolver was filtered out (IPv6-only): we cannot forward this
734 // off-tailnet name, so soft-fail rather than assert non-existence.
735 Decision::Reply(servfail)
736 } else {
737 Decision::Forward {
738 upstreams,
739 query: buf.to_vec(),
740 // All upstreams failing at runtime is also an inability to forward, not a non-existence
741 // (Go forwarder.go:1297-1307): hand the forwarder a SERVFAIL fallback, not NXDOMAIN.
742 servfail,
743 recursive,
744 }
745 }
746}
747
748/// The SERVFAIL this node synthesizes for an off-tailnet name it could not forward: no upstream to
749/// ask, or — as [`Decision::Forward`]'s fallback — every upstream failed without leaving a response
750/// [`forward_walk`] relays.
751///
752/// Go's `servfailResponse` (net/dns/resolver/forwarder.go @
753/// `e2ed432399c9b0fda7aa14e9eb27784d2d893c55`) builds it from the *query's* parsed header: ID,
754/// opcode, TC, RD, RA, AD and CD stay as the client sent them, and only QR, AA and RCODE are set.
755/// That is not [`encode_response`]'s header, which sets RA whenever RD is and clears the opcode and
756/// CD, so the flags words are copied here instead. For the RD=1, RA=0 query every stub resolver
757/// sends, the visible difference is RA: Go's SERVFAIL leaves it clear. Z is not copied, because
758/// `dnsmessage.Header` has no field to carry it. The question is echoed and nothing else is added
759/// — no OPT record, as in Go.
760fn servfail_for_query(buf: &[u8], id: u16, q: &ts_dns_wire::Question) -> Vec<u8> {
761 let mut resp = encode_response(id, q, false, Rcode::ServFail, &[], None);
762 if let ([_, _, hi, lo, ..], [_, _, query_hi, query_lo, ..]) = (resp.as_mut_slice(), buf) {
763 // QR | opcode (0x78) | AA | TC and RD (0x03) as sent.
764 *hi = 0x80 | (query_hi & 0x78) | 0x04 | (query_hi & 0x03);
765 // RA (0x80), AD (0x20) and CD (0x10) as sent; Z (0x40) dropped; RCODE = SERVFAIL.
766 *lo = (query_lo & 0xB0) | RCODE_SERVFAIL;
767 }
768 resp
769}
770
771/// The DNS query types Go's resolver explicitly leaves unimplemented for a tailnet-authoritative
772/// name, answering `RCodeNotImplemented` (NOTIMP) rather than NODATA (`net/dns/resolver/tsdns.go`
773/// `resolveLocal`: `case dns.TypeNS, dns.TypeSOA, dns.TypeAXFR, dns.TypeHINFO`). The numeric type
774/// codes: NS=2, SOA=6, HINFO=13, AXFR=252.
775fn is_unimplemented_tailnet_qtype(qtype: &ts_dns_wire::QType) -> bool {
776 matches!(qtype, ts_dns_wire::QType::Other(2 | 6 | 13 | 252))
777}
778
779/// For a query whose *qtype/qclass* we don't serve authoritatively (anything other than an IN-class
780/// A/AAAA/PTR — e.g. TXT, SRV, MX, HTTPS/SVCB, or a CHAOS-class query): forward it to upstream like
781/// any other name, but for a tailnet-authoritative name return an empty NOERROR (NODATA) instead of
782/// NXDOMAIN — except the NS/SOA/HINFO/AXFR types Go answers NOTIMP for
783/// ([`is_unimplemented_tailnet_qtype`]).
784///
785/// This mirrors Go's resolver: an authoritative name with no record of the requested type returns
786/// `RCodeSuccess` with no answers ("the name exists, but no records of that type"), NOT NXDOMAIN and
787/// NOT REFUSED; a non-authoritative name is forwarded verbatim regardless of qtype. The fork
788/// previously REFUSED every non-A/AAAA/PTR qtype (and every non-IN class) for *all* names, which a
789/// stub resolver reads as "this server won't serve me" — so it would abandon the resolver, breaking
790/// ordinary client lookups (HTTPS/SVCB type 65 issued routinely by browsers for HTTP/3 + ECH, plus
791/// MX/TXT/SRV) for off-tailnet names that A/AAAA queries already forward. Refusing these was never an
792/// anti-leak measure (the same name's A/AAAA already egresses); it was just broken interop.
793///
794/// Anti-leak is preserved: a tailnet-suffix name still never leaves this node (NODATA, not forward),
795/// exactly as the A/AAAA path keeps a positive overlay match authoritative.
796fn forward_or_nodata(
797 view: &DnsView,
798 canon: &str,
799 buf: &[u8],
800 id: u16,
801 q: &ts_dns_wire::Question,
802 rd: bool,
803) -> Decision {
804 // Authoritative tailnet name. For most unsupported types we answer NODATA (empty NOERROR) — the
805 // name exists, we just hold no record of that type. But a small set of types Go's resolver
806 // *explicitly* leaves unimplemented (`net/dns/resolver/tsdns.go` `resolveLocal`:
807 // `case dns.TypeNS, dns.TypeSOA, dns.TypeAXFR, dns.TypeHINFO: return RCodeNotImplemented`) must
808 // answer NOTIMP, not NODATA — a `dig NS`/`SOA`/`HINFO` against the tailnet zone is otherwise a
809 // clean fingerprint distinguishing this fork from real tailscaled. Off-tailnet names are
810 // unaffected (they forward below regardless of type); this NOTIMP applies only to a name we are
811 // authoritative for.
812 if is_tailnet_name(view, canon) {
813 let rcode = if is_unimplemented_tailnet_qtype(&q.qtype) {
814 Rcode::NotImpl
815 } else {
816 Rcode::NoError
817 };
818 // No SOA. Go sets `SOAZone` on a no-data answer only for an A/AAAA/ALL question; a TXT or
819 // SRV miss on a name we serve — and the NOTIMP types — go back bare, as they do upstream.
820 return Decision::Reply(encode_response(id, q, rd, rcode, &[], None));
821 }
822 // Anti-leak parity with the `QType::Ptr` arm: a reverse query for a tailnet CGNAT IPv4
823 // (100.64.0.0/10) or ANY `ip6.arpa` name must NEVER egress to an upstream resolver, regardless
824 // of qtype/class — forwarding it would reveal that a specific tailnet IP was probed. The PTR arm
825 // enforces this (NXDOMAIN) but its guards live only inside that arm; without re-checking here, an
826 // exotic-qtype (TXT/ANY/…) or non-IN-class query for a tailnet reverse name would slip through to
827 // the forward path below. Fail closed to NXDOMAIN, matching the PTR arm's disposition.
828 if is_ip6_arpa(canon) {
829 // No SOA: see the matching guard in [`decide`]'s PTR arm.
830 return Decision::Reply(encode_response(id, q, rd, Rcode::NxDomain, &[], None));
831 }
832 if let Some(octets) = q.name.ptr_to_ipv4()
833 && is_tailnet_cgnat(octets.into())
834 {
835 // Authoritative for the CGNAT reverse zone, so this NXDOMAIN carries its SOA — same
836 // disposition as the PTR arm, whatever the qtype or class that got us here.
837 return Decision::Reply(encode_response(
838 id,
839 q,
840 rd,
841 Rcode::NxDomain,
842 &[],
843 soa_for(view, &q.name, canon).as_ref(),
844 ));
845 }
846 // Off-tailnet, non-reverse-zone: forward verbatim. `forward_or_nxdomain` already forwards
847 // non-tailnet names and soft-fails (SERVFAIL) when no upstream is configured/routable; reuse it
848 // (the tailnet branch above is already handled, so its tailnet→NXDOMAIN and negative-route paths
849 // are unreachable here — this only exercises its off-tailnet forward / SERVFAIL dispositions).
850 forward_or_nxdomain(view, canon, buf, id, q, rd)
851}
852
853/// Client-side plan for a *recursive* forward: keep resolving over local UDP upstreams, or delegate
854/// the query to the active exit node's peerAPI DoH endpoint over the overlay.
855#[derive(Debug, PartialEq, Eq)]
856pub(crate) enum RecursivePlan {
857 /// Forward over UDP to these upstreams. Used when no exit node is active, or when the config
858 /// has `use_with_exit_node` resolvers (kept local even with an exit node selected).
859 Udp(Vec<SocketAddr>),
860 /// Delegate the query to the exit node's peerAPI DoH server at this overlay address.
861 Doh(SocketAddr),
862}
863
864/// Decide whether a recursive forward should stay on local UDP upstreams or be delegated to the
865/// active exit node's DoH endpoint. Pure (no I/O) so the delegation rule is unit-testable.
866///
867/// - No active exit node ([`DnsView::exit_doh`] is `None`) => keep `default_upstreams` (UDP).
868/// - Exit node active, but the config has [`use_with_exit_node`][ts_control::DnsResolver::use_with_exit_node]
869/// resolvers => those resolvers stay local (Go keeps `UseWithExitNode` resolvers when an exit node
870/// is selected); forward to them over UDP, do NOT delegate.
871/// - Exit node active, no kept-local resolvers => delegate to the exit node's DoH. Recursive DNS
872/// then egresses from the exit node, not this host (the whole point of routing through an exit
873/// node: this node's real IP is never used to resolve the peer's public names).
874pub(crate) fn recursive_plan(view: &DnsView, default_upstreams: Vec<SocketAddr>) -> RecursivePlan {
875 let Some(doh) = view.exit_doh else {
876 return RecursivePlan::Udp(default_upstreams);
877 };
878 let kept: Vec<SocketAddr> = view
879 .cfg
880 .resolvers_with_exit_node()
881 .map(DnsResolver::udp_addr)
882 // Anti-leak / IPv6-off: only ever resolve over IPv4 upstreams; never open a v6 socket.
883 .filter(SocketAddr::is_ipv4)
884 .collect();
885 if kept.is_empty() {
886 RecursivePlan::Doh(doh)
887 } else {
888 RecursivePlan::Udp(kept)
889 }
890}
891
892/// Which transport the *client* we are answering reached us over.
893///
894/// The only thing it changes is whether an answer may be marked `TC` for exceeding the
895/// UDP payload size the query advertised. That limit describes the **datagram** we would answer in
896/// (RFC 1035 §4.2.1, RFC 6891 §6.2.3); a client that reached us over TCP has no such bound
897/// (RFC 7766 §8), and marking its answer truncated sends a stub resolver that already retried over
898/// TCP — the retry `TC` asked it to make — straight back into another retry. Go draws the same line:
899/// `checkResponseSizeAndSetTC` is applied on the UDP answer path, while the TCP DNS handler
900/// installed by `acceptTCP`'s `hittingDNS` case writes the answer under a 2-byte length prefix with
901/// no size check (wgengine/netstack/netstack.go, net/dns/resolver/tsdns.go @
902/// 9ea7cba44591e0cd840c6c94d23274dd222059bf).
903#[derive(Clone, Copy, Debug, PartialEq, Eq)]
904pub(crate) enum ClientTransport {
905 /// A UDP client: the EDNS(0)-advertised (or 512-byte) datagram limit applies.
906 Udp,
907 /// A TCP client: no datagram limit applies, so no `TC` bit is added for size.
908 ///
909 /// Two things reach for it: `dns_over_tcp` (compiled with the `tun` feature — the application
910 /// netstack has no TCP listener on `100.100.100.100:53` yet), and the peerAPI DoH *server*,
911 /// which answers as `"tcp"` because the datagram budget belongs to the peer that asked, not to
912 /// us (Go `Resolver.HandlePeerDNSQuery`, see `peerapi_doh::PEER_CLIENT_TRANSPORT`).
913 Tcp,
914}
915
916/// Turn a [`Decision::Forward`] into the plan that will carry it: a recursive forward consults
917/// [`recursive_plan`] (which may delegate to the active exit node's DoH endpoint), a split-DNS
918/// forward always goes to its route's own upstreams over UDP.
919///
920/// One line of logic, but it is the point where "recursive" becomes "may egress from the exit node
921/// instead of this host", so every caller — the UDP serve loop, the `query_dns` handler, the TUN
922/// datapath's `plan_intercept` and the DNS-over-TCP connection loop — reads it from here rather than
923/// spelling the branch out again.
924pub(crate) fn forward_plan(
925 view: &DnsView,
926 upstreams: Vec<SocketAddr>,
927 recursive: bool,
928) -> RecursivePlan {
929 if recursive {
930 recursive_plan(view, upstreams)
931 } else {
932 RecursivePlan::Udp(upstreams)
933 }
934}
935
936/// Cap a forwarded upstream response to a single UDP datagram ([`MAX_UPSTREAM_RESPONSE`]) before
937/// relaying it, then — for a [`ClientTransport::Udp`] client only — mark it truncated if it is
938/// bigger than what `query`'s sender said it can receive ([`check_response_size_and_set_tc`]).
939///
940/// The two checks **compose**; they are not alternatives. The [`MAX_UPSTREAM_RESPONSE`] cap is this
941/// forwarder's own relay bound: when the response is too large it is truncated mid-message, so we
942/// set the `TC` (truncation) flag in the DNS header (byte 2, bit `0x02`) telling the stub resolver
943/// to retry over TCP — relaying a chopped answer without `TC` would surface a
944/// malformed-but-"complete" message. That flag is only set when truncation actually occurs. The
945/// second check is the *client's* bound, and never chops the body.
946///
947/// The cap runs *after* the whole message has been read (see [`MAX_UPSTREAM_RESPONSE`]), so it
948/// bounds what we relay, not what we allocate. The netstack's UDP receive ring (4096) is one byte
949/// wider than the cap (4095), so on the UDP hop the truncating branch is reachable by exactly one
950/// deliverable datagram size — the full-ring 4096-byte answer — which is the same size Go's
951/// `maxResponseBytes+1` read buffer exists to catch.
952///
953/// `via` is the hop the answer came over, and it is why the cap is not unconditional.
954/// [`MAX_UPSTREAM_RESPONSE`] is a **datagram** bound, so it applies to any answer that has to cross
955/// a datagram in either direction: one fetched over the UDP hop ([`UpstreamTransport::Udp`]), or one
956/// relayed to a [`ClientTransport::Udp`] client, which leaves here as a single UDP datagram whatever
957/// it arrived on. The one combination exempt from it is TCP end to end — an answer
958/// [`ask_upstream`] fetched over the TCP hop ([`UpstreamTransport::Tcp`]) *and* handed to a TCP
959/// client, which is the retry path RFC 1035 §4.2.1 exists to provide and the whole point of the
960/// upstream TCP hop: chopping there at 4095 would hand the stub resolver the same truncated answer
961/// it retried over TCP to escape, and the name would still not resolve. Such an answer is bounded
962/// instead by the two-byte length prefix it arrived under (64 KiB), the same bound
963/// `dns_over_tcp`'s framing and [`crate::peerapi_doh`]'s `MAX_CLIENT_RESPONSE` already impose on the
964/// way back out.
965fn cap_response(
966 query: &[u8],
967 mut resp: Vec<u8>,
968 client: ClientTransport,
969 via: UpstreamTransport,
970) -> Vec<u8> {
971 let crosses_a_datagram = client == ClientTransport::Udp || via == UpstreamTransport::Udp;
972 if crosses_a_datagram && resp.len() > MAX_UPSTREAM_RESPONSE {
973 resp.truncate(MAX_UPSTREAM_RESPONSE);
974 // The header is 12 bytes; the TC bit lives in the second flags byte (header byte 2). A
975 // capped datagram is always >= the header length, but guard anyway to never panic.
976 if let Some(flags_hi) = resp.get_mut(2) {
977 *flags_hi |= 0x02;
978 }
979 }
980 check_response_size_and_set_tc(query, resp, client)
981}
982
983/// The RFC 1035 §4.2.1 maximum size of a DNS message carried over UDP by a requestor that did not
984/// advertise an EDNS(0) buffer size. Go's `defaultUDPSize` in `checkResponseSizeAndSetTC`
985/// (net/dns/resolver/forwarder.go @ 9ea7cba44591e0cd840c6c94d23274dd222059bf).
986///
987/// It is **not** a floor under an advertised size. RFC 6891 §6.2.3 says a value below 512 "MUST be
988/// treated as equal to 512", but Go takes the advertised number verbatim (`maxSize = int(ednsSize)`)
989/// and only reaches for this constant when the request carries no usable OPT record at all. A stub
990/// that advertises 200 is told a 300-byte answer is truncated, and a Rust node on the same tailnet
991/// has to say the same thing.
992const NO_EDNS_UDP_LIMIT: usize = 512;
993
994/// The RR TYPE of an EDNS(0) OPT pseudo-record (RFC 6891 §6.1.2). In an OPT record the CLASS field
995/// is repurposed to carry the requestor's UDP payload size.
996const OPT_RR_TYPE: u16 = 41;
997
998/// Wire size of an EDNS(0) OPT record carrying **no** options: NAME (1 byte, the root label) +
999/// TYPE (2) + CLASS (2) + TTL (4) + RDLEN (2). Go's `optFixedBytes`.
1000const OPT_FIXED_BYTES: usize = 11;
1001
1002/// Set the `TC` (truncated) bit on `resp` when it is larger than the UDP payload size the client's
1003/// `query` advertised — the size in its EDNS(0) OPT record, or 512 bytes when it carries none
1004/// (RFC 1035). The body is left **intact**: `TC` tells the stub resolver the answer may not fit the
1005/// datagram it asked for, so it should retry over TCP; it is not a claim that we chopped anything.
1006///
1007/// This is Go's `checkResponseSizeAndSetTC` (net/dns/resolver/forwarder.go @
1008/// 9ea7cba44591e0cd840c6c94d23274dd222059bf), including its first statement — `if family != "udp"
1009/// { return response }`. `client` is that `family`: it names the transport the client **we answer**
1010/// used, never the hop we fetched the answer over. A [`ClientTransport::Tcp`] client has no
1011/// datagram to overflow (RFC 7766 §8) and setting `TC` for it would only send its resolver into
1012/// another retry of a transport that already has no size bound.
1013///
1014/// It runs on every path that returns an answer to a client, exactly as upstream does: the UDP and
1015/// DoH forwards (via [`cap_response`] / `forward_doh`, Go's `forwarder.send`) **and** the answers
1016/// this node builds itself (Go calls it in `Resolver.Query` right after `respond` succeeds). The
1017/// local path is not exempt: `ts_dns_wire` caps an authoritative response at 512 bytes, which only
1018/// bounds it below a *default* client limit — a client that advertised less than that can still be
1019/// overflowed by an answer we composed.
1020pub(crate) fn check_response_size_and_set_tc(
1021 query: &[u8],
1022 mut resp: Vec<u8>,
1023 client: ClientTransport,
1024) -> Vec<u8> {
1025 if client == ClientTransport::Tcp {
1026 return resp;
1027 }
1028 // The header is 12 bytes and the TC bit lives in the second flags byte (header byte 2); a
1029 // response shorter than that is not something we can (or need to) mark. Re-setting a bit that
1030 // is already set is a no-op, so upstream's `truncatedFlagSet` early return needs no analogue.
1031 if resp.len() > client_udp_limit(query)
1032 && let Some(flags_hi) = resp.get_mut(2)
1033 {
1034 *flags_hi |= 0x02;
1035 }
1036 resp
1037}
1038
1039/// The largest UDP DNS response `query`'s sender is willing to receive: the EDNS(0) advertised size
1040/// verbatim, or [`NO_EDNS_UDP_LIMIT`] when the query carries no valid OPT record. Go's
1041/// `getEDNSBufferSize` plus the `hasEDNS` branch of `checkResponseSizeAndSetTC`.
1042fn client_udp_limit(query: &[u8]) -> usize {
1043 find_opt_record(query).map_or(NO_EDNS_UDP_LIMIT, usize::from)
1044}
1045
1046/// Return the requestor's UDP payload size from `query`'s EDNS(0) OPT record, or [`None`] when the
1047/// message carries no OPT record this node will act on.
1048///
1049/// A direct port of Go's `findOPTRecord` (net/dns/resolver/forwarder.go @
1050/// 9ea7cba44591e0cd840c6c94d23274dd222059bf), and deliberately as narrow as it is: the OPT record
1051/// must occupy the **final 11 bytes** of the message, and it must have a root NAME, TYPE `OPT`,
1052/// EDNS version 0 and `RDLEN == 0`. Upstream states the restriction outright — "Only OPT records at
1053/// the very end of the message with no option codes are addressed" — and everything else is
1054/// `(0, nil)`, i.e. *no EDNS*, i.e. the 512-byte RFC 1035 limit.
1055///
1056/// That matters far more often than "malformed query" suggests. A query carrying **any** EDNS
1057/// option — a DNS cookie (RFC 7873) or EDNS Client Subnet, both of which real stub resolvers send
1058/// routinely — has `RDLEN != 0`, so upstream ignores the 4096 it advertises and caps the answer at
1059/// 512. Walking the additional section properly and honouring that 4096 would leave `TC` clear on a
1060/// 900-byte reply that every Go node on the tailnet marks truncated, and the two nodes would hand
1061/// the same stub resolver different answers to the same question. Being generous here is the bug.
1062fn find_opt_record(packet: &[u8]) -> Option<u16> {
1063 /// The only EDNS version defined (RFC 6891 §6.1.3). Go: "Be conservative and don't touch
1064 /// unknown versions."
1065 const EDNS0_VERSION: u8 = 0;
1066
1067 if packet.len() < DNS_HEADER_LEN + OPT_FIXED_BYTES {
1068 return None;
1069 }
1070 // OPT lives in the additional section, so no additional records means no OPT.
1071 if u16::from_be_bytes([packet[10], packet[11]]) == 0 {
1072 return None;
1073 }
1074
1075 let opt = &packet[packet.len() - OPT_FIXED_BYTES..];
1076 if opt[0] != 0 {
1077 return None; // NAME must be the root domain (a single zero byte).
1078 }
1079 if u16::from_be_bytes([opt[1], opt[2]]) != OPT_RR_TYPE {
1080 return None;
1081 }
1082 // CLASS is repurposed as the requestor's UDP payload size (RFC 6891 §6.1.2).
1083 let requested_size = u16::from_be_bytes([opt[3], opt[4]]);
1084 // opt[5] is the extended RCODE: ignored, as upstream ignores it.
1085 if opt[6] != EDNS0_VERSION {
1086 return None;
1087 }
1088 // opt[7..9] are the EDNS flags (DO bit and friends): ignored.
1089 if u16::from_be_bytes([opt[9], opt[10]]) != 0 {
1090 return None; // RDLEN must be 0 — the record carries no options.
1091 }
1092 Some(requested_size)
1093}
1094
1095/// The byte length of a fixed DNS header.
1096const DNS_HEADER_LEN: usize = 12;
1097
1098/// Return the byte range of the first question section (QNAME + QTYPE + QCLASS) within `msg`,
1099/// starting just after the 12-byte header. Returns [`None`] if the name is malformed, uses a
1100/// compression pointer (illegal in a question), or runs past the buffer. Used to byte-compare a
1101/// forwarded query's question against the upstream response's question.
1102fn question_range(msg: &[u8]) -> Option<std::ops::Range<usize>> {
1103 let mut off = DNS_HEADER_LEN;
1104 // Walk the QNAME label sequence to the terminating root label (0x00).
1105 loop {
1106 let len = *msg.get(off)? as usize;
1107 // A compression pointer (top two bits set) is not valid in a question section.
1108 if len & 0xC0 != 0 {
1109 return None;
1110 }
1111 off += 1;
1112 if len == 0 {
1113 break; // root label: QNAME complete.
1114 }
1115 off = off.checked_add(len)?;
1116 if off > msg.len() {
1117 return None;
1118 }
1119 }
1120 // QTYPE (2) + QCLASS (2) follow the name.
1121 let end = off.checked_add(4)?;
1122 if end > msg.len() {
1123 return None;
1124 }
1125 Some(DNS_HEADER_LEN..end)
1126}
1127
1128/// Whether `resp` is a plausible DNS response to `query`: same 16-bit transaction id, the QR
1129/// (response) bit set, and a byte-identical question section (QNAME + QTYPE + QCLASS). Both buffers
1130/// carry the DNS header in the first 12 bytes (id at [0..2], flags at [2..4], QR is the high bit of
1131/// byte 2). Used to reject off-path/forged datagrams before relaying them back to the stub resolver
1132/// as authoritative: matching only the id + QR lets an injector that guesses the id swap in an
1133/// answer for a different question, so we also require the echoed question to match.
1134fn response_matches_query(query: &[u8], resp: &[u8]) -> bool {
1135 if query.len() < DNS_HEADER_LEN || resp.len() < DNS_HEADER_LEN {
1136 return false;
1137 }
1138 let id_matches = query[0..2] == resp[0..2];
1139 let is_response = resp[2] & 0x80 != 0;
1140 if !id_matches || !is_response {
1141 return false;
1142 }
1143 // The response must echo the exact question we asked. Parse both question sections and compare
1144 // their bytes; a parse failure on either side is treated as a non-match (fail closed).
1145 match (question_range(query), question_range(resp)) {
1146 (Some(q), Some(r)) => query[q] == resp[r],
1147 _ => false,
1148 }
1149}
1150
1151/// SERVFAIL (RCODE 2): the upstream could not process the query. A *soft* error to a forwarder — the
1152/// name may still resolve through another resolver.
1153const RCODE_SERVFAIL: u8 = 2;
1154/// REFUSED (RCODE 5): the upstream will not answer this query (policy, an ACL, a view it has no
1155/// data for). Soft for the same reason: another resolver may well serve it.
1156const RCODE_REFUSED: u8 = 5;
1157
1158/// The RCODE `msg` carries: the low 4 bits of header byte 3 (RFC 1035 §4.1.1), or `None` when `msg`
1159/// is too short to have a header. The EDNS(0) *extended* RCODE bits an OPT record can add are
1160/// ignored, as they are in [`find_opt_record`] and in Go's forwarder, which reads the 4-bit
1161/// `dnsmessage.Header.RCode`.
1162fn response_rcode(msg: &[u8]) -> Option<u8> {
1163 msg.get(3).map(|b| b & 0x0F)
1164}
1165
1166/// Whether `msg` carries an RCODE a forwarder must treat as a **soft** error — one that means "this
1167/// resolver could not answer", not "here is the answer": [`RCODE_SERVFAIL`] or [`RCODE_REFUSED`].
1168/// See [`forward_query`] for what that changes.
1169fn is_soft_error(msg: &[u8]) -> bool {
1170 matches!(response_rcode(msg), Some(RCODE_SERVFAIL | RCODE_REFUSED))
1171}
1172
1173/// Forward `query` to each upstream in order over the **overlay** netstack, returning the first
1174/// well-formed response that is not a *soft* error, or — once every upstream has failed — the
1175/// failure [`forward_walk`] chooses to relay, which is often the prebuilt `fallback` buffer.
1176///
1177/// Anti-leak: forwarding goes through the overlay netstack `channel` (a fresh `0.0.0.0:0` overlay
1178/// UDP socket per query, and — whenever the TCP hop runs — a fresh `0.0.0.0:0` overlay TCP
1179/// connection to the same resolver), NEVER a host socket: the real origin IP can't leak to the
1180/// resolver, and split-DNS upstreams reachable only over the tailnet/subnet-router work. Each
1181/// upstream is bounded by [`UPSTREAM_TIMEOUT`] per hop. `client` names the transport the *client* we
1182/// answer used, not the one we fetched over — it sets how long the UDP hop runs alone before TCP
1183/// joins it ([`UDP_RACE_TIMEOUT`]) as well as how the answer is capped; `retry` says whether the
1184/// upstream hop may use TCP at all ([`DnsView::upstream_tcp_retry`]).
1185///
1186/// The socket work is all this function does; which response is relayed to the client is
1187/// [`forward_walk`]'s decision.
1188pub(crate) async fn forward_query(
1189 channel: &Channel,
1190 upstreams: &[SocketAddr],
1191 query: &[u8],
1192 fallback: Vec<u8>,
1193 client: ClientTransport,
1194 retry: TcpRetry,
1195) -> Vec<u8> {
1196 forward_walk(upstreams, query, fallback, client, |upstream| {
1197 ask_upstream(channel, upstream, query, client, retry)
1198 })
1199 .await
1200}
1201
1202/// Which transport an upstream answer was fetched over. Not a property of the client we answer
1203/// (that is [`ClientTransport`]) — it is what [`cap_response`] needs in order to know whether the
1204/// answer has already survived a datagram.
1205#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1206enum UpstreamTransport {
1207 /// The UDP hop: one datagram, bounded by the netstack's UDP receive ring.
1208 Udp,
1209 /// The TCP hop: a length-prefixed message, bounded only by its two-byte prefix. Reached only
1210 /// through [`ask_upstream`]'s race: the retry of a truncated UDP answer, or the fallback from a
1211 /// slow or failed UDP hop.
1212 Tcp,
1213}
1214
1215/// One upstream's answer as [`ask_upstream`] hands it to [`forward_walk`]: the bytes, the address
1216/// they arrived from, and the hop they arrived over.
1217#[derive(Debug)]
1218struct UpstreamAnswer {
1219 /// Where the answer came from, **unfiltered** — [`forward_walk`] is what checks it against the
1220 /// upstream we asked.
1221 from: SocketAddr,
1222 /// The DNS response bytes, verbatim.
1223 resp: Vec<u8>,
1224 /// The hop that carried them (see [`UpstreamTransport`]).
1225 via: UpstreamTransport,
1226}
1227
1228/// How one upstream failed, both hops taken together: Go's per-resolver error, which `Race.Start`
1229/// hands back as `errors.Join` of each hop's error, in the order the hops settled
1230/// (util/race/race.go @ `e2ed432399c9b0fda7aa14e9eb27784d2d893c55`).
1231///
1232/// [`forward_walk`] asks two things of that joined error, and these are the two answers. Everything
1233/// else a hop can fail with — a timeout, a connect or framing error, a message that does not answer
1234/// the query — carries no bytes and no refusal, so it only ever leaves the default behind.
1235#[derive(Debug, Default)]
1236struct UpstreamFailure {
1237 /// The first soft-error response (SERVFAIL or REFUSED, [`is_soft_error`]) either hop brought
1238 /// back: what Go's `errors.AsType[rcodeResponseError]` finds first in the joined error.
1239 first_soft: Option<UpstreamAnswer>,
1240 /// Whether either hop was answered REFUSED: Go's `errors.Is(err, errRefused)`, which is true of
1241 /// a joined error when any part of it refused.
1242 refused: bool,
1243}
1244
1245impl UpstreamFailure {
1246 /// A failure whose first soft error is `soft`.
1247 fn soft(soft: UpstreamAnswer) -> Self {
1248 let mut failure = Self::default();
1249 failure.note(soft);
1250 failure
1251 }
1252
1253 /// Add the soft error the next hop to settle came back with.
1254 fn note(&mut self, soft: UpstreamAnswer) {
1255 self.refused |= response_rcode(&soft.resp) == Some(RCODE_REFUSED);
1256 self.first_soft.get_or_insert(soft);
1257 }
1258}
1259
1260/// How long the UDP hop has the upstream to itself before the TCP hop joins the race, when the
1261/// client we answer asked over UDP. A client that asked over TCP gets no head start for UDP at all:
1262/// both hops start together.
1263///
1264/// Go's `udpRaceTimeout` (net/dns/resolver/forwarder.go @
1265/// `e2ed432399c9b0fda7aa14e9eb27784d2d893c55`). It is what lets a resolver whose UDP path drops or
1266/// stalls, but whose TCP path answers, still resolve — in about two seconds rather than not at all.
1267/// It is shorter than [`UPSTREAM_TIMEOUT`] on purpose: waiting that out first would spend the whole
1268/// UDP budget before TCP was ever tried.
1269const UDP_RACE_TIMEOUT: Duration = Duration::from_secs(2);
1270
1271/// Ask one `upstream` for `query` over the overlay and return the answer that came back, or — when
1272/// neither hop produced one — how the upstream failed ([`UpstreamFailure`]).
1273///
1274/// The query is **raced** over UDP and TCP to the same resolver, as Go's forwarder does: `send`
1275/// hands a `firstUDP` and a `thenTCP` closure to `race.New(timeout, firstUDP, thenTCP)`, and
1276/// `util/race`'s `Race.Start` runs the first at once and the second when the timeout elapses or
1277/// the first fails, whichever comes first; the first good answer wins (net/dns/resolver/forwarder.go
1278/// and util/race/race.go @ `e2ed432399c9b0fda7aa14e9eb27784d2d893c55`). [`race_udp_and_tcp`] is
1279/// that race; what it means for one query:
1280///
1281/// - **A slow or failed UDP hop falls back to TCP.** The TCP hop starts after [`UDP_RACE_TIMEOUT`]
1282/// for a [`ClientTransport::Udp`] client, at once for a [`ClientTransport::Tcp`] one, and at once
1283/// whenever the UDP hop has already failed.
1284/// - **A truncated answer is re-asked over TCP**, RFC 1035 §4.2.1. `firstUDP` maps it to an error so
1285/// the race waits for the TCP hop.
1286/// - **A SERVFAIL or REFUSED is a failed hop, not an answer**, on either hop. Go's `sendUDP` and
1287/// `sendTCP` both turn RCODE 2 and 5 into an `rcodeResponseError`, so a soft error over UDP starts
1288/// the TCP hop at once, and a soft error over TCP leaves the race to the UDP hop. The resolver
1289/// answers if either hop does; only when neither does is it a failure, carrying both hops' soft
1290/// errors for [`forward_walk`] to weigh.
1291/// - **The same resolver, never the next one.** Walking on to the next upstream would send the
1292/// query to a resolver the first one already answered — a second party learning a name it was
1293/// never asked about. [`forward_walk`] does the walking; this function only ever talks to the
1294/// `upstream` it was handed.
1295/// - **A failed TCP hop returns the truncated UDP answer**, not `SERVFAIL`. A truncated answer is
1296/// still an answer: its header and question are intact and a stub resolver can act on the `TC`
1297/// bit. Replacing it with a synthesized failure would be strictly worse than what we already had.
1298/// - **A TCP message that does not answer this query is a failed TCP hop**, not an answer. The
1299/// race checks it with [`response_matches_query`] exactly as it checks the datagram, because the
1300/// hop that loses the race is *dropped*: letting a mismatched message win would throw away the
1301/// UDP answer — pending, or held truncated — that [`forward_walk`] would otherwise have relayed,
1302/// and [`forward_walk`] would then discard the mismatch too and resolve nothing.
1303/// - **An answer that fit wins.** Once the UDP hop has one, the TCP hop is dropped (or, before the
1304/// head start runs out, never started) and its answer is never waited for.
1305///
1306/// One place differs from Go, deliberately: Go does **not** re-ask a truncated answer over TCP for
1307/// a UDP client (`firstUDP` returns it as it came "because the client can retry over TCP itself").
1308/// Here it is re-asked for every client, as before, because two of this tree's UDP-labelled clients
1309/// cannot make that retry: the application netstack serves `100.100.100.100:53` over UDP only (the
1310/// `dns_over_tcp` responder is TUN-mode), and `query_dns` is a programmatic caller with no TCP path
1311/// at all. Dropping the retry for them would turn names that resolve today into truncated answers.
1312///
1313/// `retry` is [`DnsView::upstream_tcp_retry`], and [`TcpRetry::Disabled`] shuts the TCP hop out of
1314/// the race entirely — no head-start timer, no fallback, no truncation retry — exactly as Go's
1315/// `thenTCP` waits on the context and never dials under `skipTCP`.
1316///
1317/// Anti-leak: both hops ride the overlay netstack `channel` — the TCP one via `channel.tcp_connect`
1318/// from `0.0.0.0:0`, exactly as the UDP one binds `0.0.0.0:0` there. A host socket would put this
1319/// node's real origin IP in front of the resolver and would not reach a split-DNS upstream that
1320/// only exists inside the tailnet, so the coupling is not a convenience.
1321///
1322/// This is the whole of [`forward_query`]'s I/O, split out from the walk so the policy above it —
1323/// anti-poisoning, the soft-error rules, which response is relayed — is decided (and tested) on
1324/// bytes rather than on sockets. It vouches for nothing about the answer it returns: the source
1325/// address comes back unfiltered precisely so [`forward_walk`] can check it. The checks the race
1326/// does make are not that check moved here — they only decide which hop *wins*, and every answer
1327/// that wins is still handed to [`forward_walk`] to accept or discard on its own terms.
1328async fn ask_upstream(
1329 channel: &Channel,
1330 upstream: SocketAddr,
1331 query: &[u8],
1332 client: ClientTransport,
1333 retry: TcpRetry,
1334) -> Result<UpstreamAnswer, UpstreamFailure> {
1335 race_udp_and_tcp(
1336 upstream,
1337 query,
1338 client,
1339 retry,
1340 || ask_upstream_udp(channel, upstream, query),
1341 || ask_upstream_tcp(channel, upstream, query),
1342 )
1343 .await
1344}
1345
1346/// What the UDP hop produced, as the race reads it.
1347enum UdpOutcome {
1348 /// A good answer: it wins the race.
1349 Answer(UpstreamAnswer),
1350 /// A genuine answer that did not fit the datagram ([`did_not_fit_the_datagram`]): the race
1351 /// waits for the TCP hop, and falls back to this if that hop fails.
1352 Truncated(UpstreamAnswer),
1353 /// A SERVFAIL or REFUSED that does answer this query ([`is_soft_error`]). A failed hop, as Go's
1354 /// `sendUDP` returns it as an `rcodeResponseError`: it starts the TCP hop at once, and it is
1355 /// kept, because it is what [`forward_walk`] relays if the TCP hop does no better.
1356 SoftError(UpstreamAnswer),
1357 /// A datagram from the wrong source, or one that does not echo the question we asked. It is
1358 /// not an answer, and it does not start the TCP hop either — see [`race_udp_and_tcp`].
1359 Unmatched(UpstreamAnswer),
1360 /// Nothing usable came back.
1361 Failed,
1362}
1363
1364/// Read the UDP hop's `(source address, datagram)` as a [`UdpOutcome`].
1365fn classify_udp(
1366 upstream: SocketAddr,
1367 query: &[u8],
1368 got: Option<(SocketAddr, Vec<u8>)>,
1369) -> UdpOutcome {
1370 let Some((from, resp)) = got else {
1371 return UdpOutcome::Failed;
1372 };
1373 let answer = UpstreamAnswer {
1374 from,
1375 resp,
1376 via: UpstreamTransport::Udp,
1377 };
1378 // This repeats `forward_walk`'s check rather than moving it — the answer still goes back
1379 // unfiltered — so that an off-path injector cannot conscript this node into connecting to a
1380 // resolver by spraying forged `TC` datagrams it could never have matched the question of.
1381 if answer.from.ip() != upstream.ip() || !response_matches_query(query, &answer.resp) {
1382 return UdpOutcome::Unmatched(answer);
1383 }
1384 // The RCODE is read before the TC bit, as Go's `sendUDP` returns on it before `firstUDP` ever
1385 // looks for truncation: a truncated SERVFAIL is a SERVFAIL.
1386 if is_soft_error(&answer.resp) {
1387 UdpOutcome::SoftError(answer)
1388 } else if did_not_fit_the_datagram(&answer.resp) {
1389 UdpOutcome::Truncated(answer)
1390 } else {
1391 UdpOutcome::Answer(answer)
1392 }
1393}
1394
1395/// What the TCP hop produced, as the race reads it.
1396enum TcpOutcome {
1397 /// A good answer: it wins the race.
1398 Answer(UpstreamAnswer),
1399 /// A SERVFAIL or REFUSED that answers this query: a failed hop, as Go's `sendTCP` returns it as
1400 /// an `rcodeResponseError`, kept for [`forward_walk`] as the UDP one is.
1401 SoftError(UpstreamAnswer),
1402 /// Nothing usable came back — including a message that does not answer this query.
1403 Failed,
1404}
1405
1406/// Close out a race no hop won. `failure` is what the hops failed with; `unmatched` is a datagram
1407/// the UDP hop brought that does not answer the query, which goes back for [`forward_walk`] to
1408/// discard, as it always has — unless a hop failed with a soft error, whose bytes the walk may
1409/// relay and which the discard would throw away.
1410fn race_lost(
1411 failure: UpstreamFailure,
1412 unmatched: Option<UpstreamAnswer>,
1413) -> Result<UpstreamAnswer, UpstreamFailure> {
1414 match unmatched {
1415 Some(datagram) if failure.first_soft.is_none() => Ok(datagram),
1416 _ => Err(failure),
1417 }
1418}
1419
1420/// The race [`ask_upstream`] runs, over the two hops as plain futures: `udp` returns the
1421/// `(source address, datagram)` of the UDP hop, `tcp` the message body of the TCP hop. `tcp` is
1422/// only called when the TCP hop actually starts, so "was a TCP connection opened" is "was `tcp`
1423/// called".
1424///
1425/// The shape is Go's `Race.Start`, with the UDP hop's result classified as `firstUDP` classifies
1426/// it and the TCP hop's as `sendTCP` does:
1427///
1428/// 1. The UDP hop starts at once. For a [`ClientTransport::Udp`] client it runs alone for
1429/// [`UDP_RACE_TIMEOUT`]; if it settles inside that window, its answer is returned, except that a
1430/// **failed** hop starts TCP straight away (Go closes `startFallback`) — a SERVFAIL or REFUSED
1431/// being one — and a **truncated** one does too (Go's `truncatedResponseError`).
1432/// 2. Otherwise — the head start ran out, or the client asked over TCP and there is none — the TCP
1433/// hop starts and both run. The first good answer wins; a hop that fails leaves the race to the
1434/// other; a truncated UDP answer is held and returned only if TCP does not produce one.
1435///
1436/// When neither hop answers, the result is an [`UpstreamFailure`] holding every soft error the
1437/// hops brought back, in the order they settled — Go's `errors.Join` of the two hops' errors. A
1438/// held truncated answer beats it, as Go's `send` returns a `truncatedResponseError` it finds in the
1439/// joined error in place of the error.
1440///
1441/// A datagram that does not match the query ([`UdpOutcome::Unmatched`]) is the one divergence in
1442/// how a failed UDP hop is treated. Go's `sendUDP` turns a transaction-id mismatch into an error,
1443/// which starts the TCP hop at once; here it never *starts* the TCP hop, so a forged datagram buys
1444/// no connection that silence would not have bought anyway. If the TCP hop is already running it
1445/// is still waited for, and if it is not the datagram is handed on for [`forward_walk`] to discard,
1446/// as it always was.
1447///
1448/// Split from the sockets for the same reason [`forward_walk`] is: the decision — when TCP starts,
1449/// which answer wins — is the part that can regress silently, and here it is testable without a
1450/// netstack.
1451async fn race_udp_and_tcp<UdpFut, TcpFut>(
1452 upstream: SocketAddr,
1453 query: &[u8],
1454 client: ClientTransport,
1455 retry: TcpRetry,
1456 udp: impl FnOnce() -> UdpFut,
1457 tcp: impl FnOnce() -> TcpFut,
1458) -> Result<UpstreamAnswer, UpstreamFailure>
1459where
1460 UdpFut: std::future::Future<Output = Option<(SocketAddr, Vec<u8>)>>,
1461 TcpFut: std::future::Future<Output = Option<Vec<u8>>>,
1462{
1463 // The TCP hop's message as the race reads it. A message that does not echo this query is no
1464 // more use than no message at all: `forward_walk` discards it either way. The difference is
1465 // what it costs on the way, because the hop that loses this race is dropped unfinished: let a
1466 // mismatched message win and the UDP answer it beat is gone, and a query that would have
1467 // resolved resolves as SERVFAIL. So it is scored as a failed hop, which leaves the race to UDP
1468 // — pending, or held truncated — exactly as a connect error would.
1469 let over_tcp = |got: Option<Vec<u8>>| {
1470 let Some(resp) = got else {
1471 return TcpOutcome::Failed;
1472 };
1473 if !response_matches_query(query, &resp) {
1474 tracing::debug!(%upstream, "magic dns dropping mismatched tcp response");
1475 return TcpOutcome::Failed;
1476 }
1477 let answer = UpstreamAnswer {
1478 // The TCP peer is the resolver we connected to, by construction of the handshake, so
1479 // the source address is `upstream` itself and `forward_walk`'s source check passes on
1480 // a fact rather than on a claim in a datagram header.
1481 from: upstream,
1482 resp,
1483 via: UpstreamTransport::Tcp,
1484 };
1485 if is_soft_error(&answer.resp) {
1486 TcpOutcome::SoftError(answer)
1487 } else {
1488 TcpOutcome::Answer(answer)
1489 }
1490 };
1491 // The UDP hop has failed — `failure` is how, `unmatched` any datagram it brought that does not
1492 // answer the query — and the race is the TCP hop's.
1493 let then_tcp = |mut failure: UpstreamFailure,
1494 unmatched: Option<UpstreamAnswer>,
1495 got: Option<Vec<u8>>| match over_tcp(got) {
1496 TcpOutcome::Answer(answer) => Ok(answer),
1497 TcpOutcome::SoftError(soft) => {
1498 failure.note(soft);
1499 race_lost(failure, unmatched)
1500 }
1501 TcpOutcome::Failed => race_lost(failure, unmatched),
1502 };
1503 // The TCP hop has failed — `failure` is how — or is not in the race at all, and the race is the
1504 // UDP hop's, whatever it brings: a truncated answer included, which Go returns in place of the
1505 // error.
1506 let then_udp =
1507 |mut failure: UpstreamFailure, got: Option<(SocketAddr, Vec<u8>)>| match classify_udp(
1508 upstream, query, got,
1509 ) {
1510 UdpOutcome::Answer(answer) | UdpOutcome::Truncated(answer) => Ok(answer),
1511 UdpOutcome::SoftError(soft) => {
1512 failure.note(soft);
1513 Err(failure)
1514 }
1515 UdpOutcome::Unmatched(datagram) => race_lost(failure, Some(datagram)),
1516 UdpOutcome::Failed => Err(failure),
1517 };
1518 // A truncated UDP answer is held while the TCP hop retries it; anything short of a TCP answer
1519 // — a failed hop, a mismatched message, a SERVFAIL or REFUSED — returns the held answer.
1520 let retry_truncated = |truncated: UpstreamAnswer, got: Option<Vec<u8>>| match over_tcp(got) {
1521 TcpOutcome::Answer(answer) => answer,
1522 TcpOutcome::SoftError(_) | TcpOutcome::Failed => {
1523 tracing::debug!(
1524 %upstream,
1525 "magic dns upstream tcp hop failed, relaying the udp answer"
1526 );
1527 truncated
1528 }
1529 };
1530
1531 let udp = udp();
1532 if retry == TcpRetry::Disabled {
1533 // Control said no (`dns-forwarder-disable-tcp-retries`): the TCP hop never runs, on any
1534 // arm, so whatever UDP gives us — an answer, truncated or not, a soft error, or nothing —
1535 // is the whole result.
1536 return then_udp(UpstreamFailure::default(), udp.await);
1537 }
1538 let mut udp = std::pin::pin!(udp);
1539
1540 // Step 1: the UDP hop's head start. A TCP client gets none.
1541 if client == ClientTransport::Udp {
1542 let settled = tokio::select! {
1543 biased;
1544 got = &mut udp => Some(classify_udp(upstream, query, got)),
1545 () = tokio::time::sleep(UDP_RACE_TIMEOUT) => None,
1546 };
1547 match settled {
1548 Some(UdpOutcome::Answer(answer) | UdpOutcome::Unmatched(answer)) => {
1549 return Ok(answer);
1550 }
1551 Some(UdpOutcome::Truncated(truncated)) => {
1552 tracing::debug!(%upstream, "magic dns upstream answer truncated, retrying over tcp");
1553 return Ok(retry_truncated(truncated, tcp().await));
1554 }
1555 Some(UdpOutcome::SoftError(soft)) => {
1556 tracing::debug!(
1557 %upstream,
1558 rcode = response_rcode(&soft.resp),
1559 "magic dns upstream udp soft error, trying tcp"
1560 );
1561 return then_tcp(UpstreamFailure::soft(soft), None, tcp().await);
1562 }
1563 Some(UdpOutcome::Failed) => {
1564 tracing::debug!(%upstream, "magic dns upstream udp hop failed, trying tcp");
1565 return then_tcp(UpstreamFailure::default(), None, tcp().await);
1566 }
1567 None => {
1568 tracing::debug!(%upstream, "magic dns upstream udp hop slow, racing tcp");
1569 }
1570 }
1571 }
1572
1573 // Step 2: both hops run; the first good answer wins.
1574 let mut tcp = std::pin::pin!(tcp());
1575 tokio::select! {
1576 biased;
1577 got = &mut udp => match classify_udp(upstream, query, got) {
1578 UdpOutcome::Answer(answer) => Ok(answer),
1579 UdpOutcome::Truncated(held) => Ok(retry_truncated(held, tcp.await)),
1580 UdpOutcome::SoftError(soft) => {
1581 then_tcp(UpstreamFailure::soft(soft), None, tcp.await)
1582 }
1583 UdpOutcome::Unmatched(held) => {
1584 then_tcp(UpstreamFailure::default(), Some(held), tcp.await)
1585 }
1586 UdpOutcome::Failed => then_tcp(UpstreamFailure::default(), None, tcp.await),
1587 },
1588 got = &mut tcp => match over_tcp(got) {
1589 TcpOutcome::Answer(answer) => Ok(answer),
1590 TcpOutcome::SoftError(soft) => then_udp(UpstreamFailure::soft(soft), udp.await),
1591 TcpOutcome::Failed => then_udp(UpstreamFailure::default(), udp.await),
1592 },
1593 }
1594}
1595/// Whether `msg` is an upstream answer that did not fit the datagram it came in — the thing the TCP
1596/// retry exists for. Two ways to be one, and Go's forwarder retries on both:
1597///
1598/// - The resolver said so: the `TC` bit, header byte 2, bit `0x02` (RFC 1035 §4.1.1).
1599/// - It is longer than this forwarder will relay in one datagram ([`MAX_UPSTREAM_RESPONSE`]), so
1600/// [`cap_response`] is about to cut it and set `TC` itself. That is the same "did not fit" Go
1601/// detects by reading `sendUDP` into a `maxResponseBytes+1` buffer and then setting `TC` on the
1602/// reply it returns — which is the very bit its caller then tests. Retrying on it here keeps the
1603/// one deliverable oversize datagram the netstack's 4096-byte receive ring can hand us from being
1604/// a name that resolves only as a cut answer.
1605///
1606/// A message too short to hold a header is neither — it is garbage, and [`forward_walk`] discards
1607/// it.
1608fn did_not_fit_the_datagram(msg: &[u8]) -> bool {
1609 msg.len() > MAX_UPSTREAM_RESPONSE || msg.get(2).is_some_and(|flags_hi| flags_hi & 0x02 != 0)
1610}
1611
1612/// The UDP hop: one `0.0.0.0:0` overlay socket, one datagram out, the first datagram back as
1613/// `(source address, bytes)`. `None` on bind/send/recv failure, [`UPSTREAM_TIMEOUT`], or an empty
1614/// datagram.
1615async fn ask_upstream_udp(
1616 channel: &Channel,
1617 upstream: SocketAddr,
1618 query: &[u8],
1619) -> Option<(SocketAddr, Vec<u8>)> {
1620 let socket = match channel
1621 .udp_bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0)))
1622 .await
1623 {
1624 Ok(s) => s,
1625 Err(e) => {
1626 tracing::warn!(error = %e, %upstream, "magic dns upstream bind failed");
1627 return None;
1628 }
1629 };
1630
1631 if let Err(e) = socket.send_to(upstream, query).await {
1632 tracing::warn!(error = %e, %upstream, "magic dns upstream send failed");
1633 return None;
1634 }
1635
1636 match timeout(UPSTREAM_TIMEOUT, socket.recv_from_bytes()).await {
1637 Ok(Ok((from, resp))) if !resp.is_empty() => Some((from, resp.to_vec())),
1638 Ok(Ok(_)) => None,
1639 Ok(Err(e)) => {
1640 tracing::warn!(error = %e, %upstream, "magic dns upstream recv failed");
1641 None
1642 }
1643 Err(_) => {
1644 tracing::debug!(%upstream, "magic dns upstream timed out");
1645 None
1646 }
1647 }
1648}
1649
1650/// The TCP hop: connect to `upstream` over the overlay from `0.0.0.0:0` and exchange one
1651/// length-prefixed message (RFC 1035 §4.2.2). `None` on any failure — connect refused, framing
1652/// error, EOF, or [`UPSTREAM_TIMEOUT`] over the whole exchange — which leaves [`race_udp_and_tcp`]
1653/// with whatever the UDP hop brings, a truncated answer included.
1654///
1655/// The timeout covers connect **and** transfer together, so a resolver that accepts the connection
1656/// and then stalls cannot hold the forward open for longer than a resolver that never answers at
1657/// all.
1658async fn ask_upstream_tcp(
1659 channel: &Channel,
1660 upstream: SocketAddr,
1661 query: &[u8],
1662) -> Option<Vec<u8>> {
1663 let local = SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0));
1664 let exchange = async {
1665 let mut stream = channel
1666 .tcp_connect(local, upstream)
1667 .await
1668 .map_err(|e| std::io::Error::other(e.to_string()))?;
1669 tcp_exchange(&mut stream, query).await
1670 };
1671
1672 match timeout(UPSTREAM_TIMEOUT, exchange).await {
1673 Ok(Ok(resp)) if !resp.is_empty() => Some(resp),
1674 Ok(Ok(_)) => None,
1675 Ok(Err(e)) => {
1676 tracing::warn!(error = %e, %upstream, "magic dns upstream tcp retry failed");
1677 None
1678 }
1679 Err(_) => {
1680 tracing::debug!(%upstream, "magic dns upstream tcp retry timed out");
1681 None
1682 }
1683 }
1684}
1685
1686/// Write `query` and read one response on `stream`, both under the two-byte big-endian length
1687/// prefix DNS-over-TCP frames messages with (RFC 1035 §4.2.2).
1688///
1689/// Generic over the stream so the framing that actually ships is the framing the tests exercise,
1690/// without standing up a netstack — the same reason `dns_over_tcp::serve_conn`, which is this
1691/// framing read from the other end, is generic.
1692///
1693/// The prefix and the query go out in **one** write, as RFC 7766 §8 asks, so the length does not
1694/// leave as its own two-byte segment ahead of the body. The response allocation is bounded by the
1695/// prefix itself: at most 64 KiB, which is every DNS message that can be framed at all.
1696async fn tcp_exchange<S>(stream: &mut S, query: &[u8]) -> std::io::Result<Vec<u8>>
1697where
1698 S: AsyncRead + AsyncWrite + Unpin,
1699{
1700 let len = u16::try_from(query.len()).map_err(|_| {
1701 std::io::Error::new(
1702 std::io::ErrorKind::InvalidInput,
1703 "query too long to frame over tcp",
1704 )
1705 })?;
1706 let mut framed = Vec::with_capacity(2 + query.len());
1707 framed.extend_from_slice(&len.to_be_bytes());
1708 framed.extend_from_slice(query);
1709 stream.write_all(&framed).await?;
1710 stream.flush().await?;
1711
1712 let mut len_buf = [0u8; 2];
1713 stream.read_exact(&mut len_buf).await?;
1714 let len = usize::from(u16::from_be_bytes(len_buf));
1715 if len == 0 {
1716 return Err(std::io::Error::new(
1717 std::io::ErrorKind::InvalidData,
1718 "zero-length dns response over tcp",
1719 ));
1720 }
1721 let mut resp = vec![0u8; len];
1722 stream.read_exact(&mut resp).await?;
1723 Ok(resp)
1724}
1725
1726/// Walk `upstreams` in order, asking `ask` for each one's answer, and decide which response the
1727/// client gets. [`forward_query`] is the only caller; `ask` is its overlay socket exchange
1728/// ([`ask_upstream`]).
1729///
1730/// **REFUSED and SERVFAIL are soft errors, not answers** ([`is_soft_error`]). An upstream that
1731/// answers `REFUSED` (RCODE 5) or `SERVFAIL` (RCODE 2) — on both of [`ask_upstream`]'s hops, since
1732/// a soft error over UDP is first retried over TCP to the same resolver — does not end the forward:
1733/// the walk continues to the next upstream, and how the first failure looked is remembered for when
1734/// the list is exhausted with nothing better. Otherwise a broken or misconfigured resolver that
1735/// refuses instantly beats a healthy one still doing the work, and the stub resolver is handed the
1736/// refusal as though it were the answer — complete DNS failure exactly where a split-DNS route or a
1737/// fallback list names more than one resolver, which is the shape control commonly pushes. Every
1738/// other RCODE — including NXDOMAIN, which is a real answer — ends the walk on the spot.
1739///
1740/// The caller supplies `fallback` (a SERVFAIL response for a forwarded off-tailnet name — an
1741/// all-upstream failure is a soft "couldn't resolve", not a cacheable non-existence, matching Go
1742/// forwarder.go:1297-1307). Keeping it caller-supplied means this fn is rcode-agnostic. When every
1743/// upstream failed, the client gets an upstream's own bytes — which can carry an RFC 8914 extended
1744/// DNS error saying *why* it failed — in exactly the two cases Go's `forwardWithDestChan` relays
1745/// them (net/dns/resolver/forwarder.go @ e2ed432399c9b0fda7aa14e9eb27784d2d893c55, `firstErr` /
1746/// `sawNonRefused`, lines 1309-1383):
1747///
1748/// - **every** upstream was refused: the first failure's first soft error is relayed;
1749/// - otherwise, the first failure's first soft error is relayed only if it is a SERVFAIL.
1750///
1751/// Anything else gets `fallback`. So a REFUSED followed by a SERVFAIL, or by an upstream that never
1752/// answered, is a SERVFAIL — the refusal describes one resolver's policy, not why the forward as a
1753/// whole failed. "Never answered" is every upstream that timed out, errored, or only ever sent
1754/// datagrams the anti-poisoning check discarded; it counts as a failure that is not a refusal, as
1755/// Go's transport errors (and its transaction-id mismatch) do.
1756///
1757/// An upstream's failure is both of its hops together ([`UpstreamFailure`]), read the way Go reads
1758/// the joined error its race returns: the upstream *was refused* when either hop was answered
1759/// REFUSED (`errors.Is(err, errRefused)`), and its soft error is whichever hop's settled first
1760/// (`errors.AsType[rcodeResponseError]`). So a SERVFAIL over UDP and a REFUSED over TCP is a
1761/// refusal whose relayed bytes are the SERVFAIL, as it is in Go.
1762/// "First" upstream is the walk's order here; Go takes it by arrival, since it asks every resolver
1763/// at once.
1764///
1765/// Every relayed answer goes through [`cap_response`], which caps it at [`MAX_UPSTREAM_RESPONSE`]
1766/// unless it was TCP end to end, and — for a [`ClientTransport::Udp`] client — marks it truncated
1767/// when it exceeds what `query` advertised it can receive. That is why an [`UpstreamAnswer`] carries
1768/// the hop it arrived over all the way to here: the walk never inspects it, it only hands it on. A
1769/// relayed soft error does not: Go hands it back as it was read ([`relay_soft_error`]).
1770async fn forward_walk<F, Fut>(
1771 upstreams: &[SocketAddr],
1772 query: &[u8],
1773 fallback: Vec<u8>,
1774 client: ClientTransport,
1775 mut ask: F,
1776) -> Vec<u8>
1777where
1778 F: FnMut(SocketAddr) -> Fut,
1779 Fut: std::future::Future<Output = Result<UpstreamAnswer, UpstreamFailure>>,
1780{
1781 // How the first failing upstream failed: Go's `firstErr`.
1782 let mut first_failure: Option<UpstreamFailure> = None;
1783 // Whether any upstream failed other than by being refused: Go's `sawNonRefused`.
1784 let mut saw_non_refused = false;
1785
1786 for upstream in upstreams {
1787 let failure = match ask(*upstream).await {
1788 // Anti-poisoning: only accept a datagram that came from the upstream we queried and
1789 // whose DNS header matches this query (same transaction id, QR=response bit set). An
1790 // off-path injector racing the real answer is otherwise relayed straight back to the
1791 // stub resolver as authoritative — and this check runs FIRST, so an injected refusal is
1792 // not even eligible to become the soft error a fully-refused forward ends up relaying.
1793 Ok(UpstreamAnswer { from, resp, .. })
1794 if from.ip() != upstream.ip() || !response_matches_query(query, &resp) =>
1795 {
1796 tracing::debug!(%upstream, %from, "magic dns dropping unsolicited/mismatched response");
1797 UpstreamFailure::default()
1798 }
1799 // [`ask_upstream`] hands soft errors back as failures, but a soft error is not an
1800 // answer however it arrives.
1801 Ok(answer) if is_soft_error(&answer.resp) => UpstreamFailure::soft(answer),
1802 Ok(UpstreamAnswer { resp, via, .. }) => return cap_response(query, resp, client, via),
1803 Err(failure) => failure,
1804 };
1805
1806 // A failure is not an answer: note how the first one looked and give the remaining
1807 // upstreams their turn.
1808 tracing::debug!(
1809 %upstream,
1810 refused = failure.refused,
1811 rcode = failure.first_soft.as_ref().and_then(|soft| response_rcode(&soft.resp)),
1812 "magic dns upstream failed, trying the next upstream"
1813 );
1814 saw_non_refused |= !failure.refused;
1815 first_failure.get_or_insert(failure);
1816 }
1817
1818 // Nothing better arrived. Relay an upstream's own bytes (extended DNS error and all) only where
1819 // Go does: the first failure's soft error when every upstream was refused, or when that soft
1820 // error is itself a SERVFAIL. A refusal mixed with any other failure, or nobody answering, is
1821 // `fallback`.
1822 match first_failure.and_then(|failure| failure.first_soft) {
1823 Some(soft) if !saw_non_refused || response_rcode(&soft.resp) == Some(RCODE_SERVFAIL) => {
1824 relay_soft_error(soft, client)
1825 }
1826 _ => fallback,
1827 }
1828}
1829
1830/// An upstream's own SERVFAIL or REFUSED, as [`forward_walk`] relays it when the forward failed.
1831///
1832/// Go relays `rcodeResponseError.res` as it was read. `sendUDP` and `sendTCP` return it from their
1833/// RCODE switch, ahead of the `TC` fix-up, `checkResponseSizeAndSetTC` and `clampEDNSSize` that an
1834/// answer goes through, and neither `forwardWithDestChan` nor `Resolver.Query` size-checks it
1835/// afterwards (net/dns/resolver/forwarder.go and tsdns.go @
1836/// `e2ed432399c9b0fda7aa14e9eb27784d2d893c55`). So, unlike [`cap_response`], this never sets `TC`
1837/// — not for the client's advertised EDNS size, and not for anything else.
1838///
1839/// The one bound it keeps is [`MAX_UPSTREAM_RESPONSE`], applied without `TC`, to a response that
1840/// crosses a datagram. Over the UDP hop that bound is Go's own: `sendUDP` cuts what it read to
1841/// `maxResponseBytes` before it looks at the RCODE. Fetched over TCP for a UDP client, it is this
1842/// node's datagram bound; Go would send the message whole.
1843fn relay_soft_error(soft: UpstreamAnswer, client: ClientTransport) -> Vec<u8> {
1844 let UpstreamAnswer { mut resp, via, .. } = soft;
1845 let crosses_a_datagram = client == ClientTransport::Udp || via == UpstreamTransport::Udp;
1846 if crosses_a_datagram && resp.len() > MAX_UPSTREAM_RESPONSE {
1847 resp.truncate(MAX_UPSTREAM_RESPONSE);
1848 }
1849 resp
1850}
1851
1852/// Run the receive/answer loop for the bound socket until it (or the netstack) goes away.
1853///
1854/// Authoritative answers are sent inline. Forwarded queries are handled on spawned tasks (each
1855/// cloning the overlay `channel`) so a slow upstream never blocks other queries.
1856async fn serve(
1857 socket: netstack::netsock::UdpSocket,
1858 rx: watch::Receiver<Arc<DnsView>>,
1859 channel: Channel,
1860) {
1861 let socket = Arc::new(socket);
1862 let mut forwards = JoinSet::new();
1863 // Bounds concurrent in-flight forwards (see `MAX_INFLIGHT_FORWARDS`); a permit is held for the
1864 // lifetime of each spawned forward task and released on completion.
1865 let inflight = Arc::new(Semaphore::new(MAX_INFLIGHT_FORWARDS));
1866 loop {
1867 let (src, buf) = match socket.recv_from_bytes().await {
1868 Ok(pkt) => pkt,
1869 Err(e) => {
1870 tracing::warn!(error = %e, "magic dns socket recv failed, stopping responder");
1871 return;
1872 }
1873 };
1874
1875 // Read the freshest view per packet.
1876 let view = rx.borrow().clone();
1877
1878 match decide(&view, &buf) {
1879 // Malformed query: drop silently.
1880 None => continue,
1881 Some(Decision::Reply(resp)) => {
1882 // Upstream runs the same size check on a locally-composed answer as on a forwarded
1883 // one (Go `Resolver.Query` calls `checkResponseSizeAndSetTC` right after `respond`).
1884 // An authoritative answer is capped at 512 bytes, but a client that advertised less
1885 // than that is still owed the `TC` bit.
1886 let resp = check_response_size_and_set_tc(&buf, resp, ClientTransport::Udp);
1887 if let Err(e) = socket.send_to(src, &resp).await {
1888 tracing::warn!(error = %e, %src, "magic dns response send failed");
1889 }
1890 }
1891 Some(Decision::Forward {
1892 upstreams,
1893 query,
1894 servfail,
1895 recursive,
1896 }) => {
1897 // A recursive forward is eligible for exit-node DoH delegation; a split-DNS route
1898 // always stays on its configured upstreams. Decide the plan against the current
1899 // view so a query routed while an exit node is active egresses from that exit node.
1900 let plan = forward_plan(&view, upstreams, recursive);
1901 // Fail closed at the in-flight cap: drop the query (the stub resolver retries or
1902 // times out) rather than spawn an unbounded task that pins an overlay socket for up
1903 // to UPSTREAM_TIMEOUT. The permit is moved into the task as a named `_permit` binding
1904 // (NOT `let _ =`, which would drop it immediately) so it is released only when the
1905 // task body completes.
1906 let Ok(permit) = inflight.clone().try_acquire_owned() else {
1907 tracing::warn!(
1908 %src,
1909 max = MAX_INFLIGHT_FORWARDS,
1910 "magic dns drop: at max in-flight forwarded queries"
1911 );
1912 continue;
1913 };
1914 let socket = socket.clone();
1915 let channel = channel.clone();
1916 // Read the retry switch off the same view this query was decided against, before
1917 // the spawn takes the query away from it.
1918 let retry = view.upstream_tcp_retry();
1919 forwards.spawn(async move {
1920 let _permit = permit;
1921 let resp = match plan {
1922 RecursivePlan::Udp(upstreams) => {
1923 forward_query(
1924 &channel,
1925 &upstreams,
1926 &query,
1927 servfail,
1928 ClientTransport::Udp,
1929 retry,
1930 )
1931 .await
1932 }
1933 RecursivePlan::Doh(doh_addr) => {
1934 crate::peerapi_doh::forward_doh(
1935 &channel,
1936 doh_addr,
1937 &query,
1938 servfail,
1939 ClientTransport::Udp,
1940 )
1941 .await
1942 }
1943 };
1944 if let Err(e) = socket.send_to(src, &resp).await {
1945 tracing::warn!(error = %e, %src, "magic dns forwarded response send failed");
1946 }
1947 });
1948 }
1949 }
1950
1951 // Reap finished forward tasks without blocking. The unreaped completed-handle backlog is
1952 // bounded by MAX_INFLIGHT_FORWARDS (a task spawns only after acquiring a permit, and there
1953 // are at most that many), so this bounds JoinSet memory too — not just the reap cadence.
1954 while forwards.try_join_next().is_some() {}
1955 }
1956}
1957
1958/// The MagicDNS responder actor.
1959///
1960/// Subscribes to control state (for the DNS config + self node) and peer state (for the peer
1961/// database), keeping a [`DnsView`] that the spawned answer loop reads for every query.
1962///
1963/// The peerAPI server task it owns also needs the live packet filter for its DoH source gate. That
1964/// one does **not** arrive on the bus: the `Args` carry a
1965/// [`LiveFilterRx`](crate::packetfilter::LiveFilterRx) written by the packet-filter updater itself,
1966/// because a fail-closed gate cannot use a lossy transport — see that alias for the two ways the bus
1967/// loses a filter, and what each one costs the gate.
1968pub struct MagicDnsActor {
1969 /// Keeps the socket-serving task alive for the lifetime of the actor.
1970 _joinset: JoinSet<()>,
1971 /// The latest view, shared with the answer loop.
1972 view_tx: watch::Sender<Arc<DnsView>>,
1973 /// The runtime [`Env`], retained so each view rebuild (the `StateUpdate` / `PeerState` handlers)
1974 /// can re-read the live [`Env::accept_dns`] cell. Unlike `enable_ipv6` (snapshotted once at
1975 /// spawn), `accept_dns` is runtime-settable via `Device::set_accept_dns`, so it must be read at
1976 /// rebuild time — not captured once — for a toggle to reach the served view.
1977 env: Env,
1978 /// The overlay channel, retained so the [`Query`] handler can run a query through the same
1979 /// forward path the serve loop uses ([`forward_query`] / [`forward_doh`], both binding
1980 /// `0.0.0.0:0` on this channel — never a host socket).
1981 channel: Channel,
1982}
1983
1984/// A programmatic DNS query routed through the live MagicDNS responder (the `100.100.100.100` path),
1985/// for [`Device::query_dns`](crate::Device::query_dns). The handler synthesizes a query packet and
1986/// drives it through the exact same [`decide`]/forward logic as an on-the-wire query, so the result
1987/// (and its anti-leak posture) matches what a tailnet client would observe.
1988pub struct Query {
1989 /// The canonical name to resolve (e.g. `example.com`, no trailing dot).
1990 pub name: String,
1991 /// The DNS query type (`1`=A, `28`=AAAA, `12`=PTR, or any other RFC 1035 TYPE).
1992 pub qtype: u16,
1993}
1994
1995/// The outcome of a `Query`: the raw DNS response bytes, the RCODE, and which upstream resolvers
1996/// (if any) were consulted. The response is returned as raw bytes (matching Go `LocalClient.QueryDNS`)
1997/// rather than parsed records — this fork's wire codec has no answer-record decoder.
1998///
1999/// (`Query` is the crate-internal actor message; not linked here as it is a private item — a
2000/// `pub` doc cannot intra-doc-link to it without erroring under the doc-lint gate.)
2001#[derive(Debug, Clone, kameo::Reply)]
2002pub struct DnsQueryResult {
2003 /// The raw DNS response datagram (header + question + any answer records).
2004 pub response: Vec<u8>,
2005 /// The RCODE from the response header's low 4 bits (`0`=NoError, `2`=SERVFAIL, `3`=NXDOMAIN,
2006 /// `5`=Refused, …).
2007 pub rcode: u8,
2008 /// The upstream resolver(s) the query was forwarded to. For a UDP forward this is the candidate
2009 /// list tried in order (the forwarder returns on the first that answers); for an exit-node DoH
2010 /// forward it is the single DoH endpoint. Empty for a locally-answered query (an authoritative
2011 /// tailnet name, a NODATA, or a fail-closed NXDOMAIN — nothing egressed).
2012 pub resolvers_consulted: Vec<SocketAddr>,
2013}
2014
2015impl kameo::Actor for MagicDnsActor {
2016 type Args = (Env, Channel, crate::packetfilter::LiveFilterRx);
2017 type Error = Error;
2018
2019 /// `filter_rx` is the live compiled packet filter for the peerAPI DoH source gate
2020 /// (`peerapi_doh::dns_source_allowed`, Go `isPeerAPIDNSAllowed`), handed in from
2021 /// `Runtime::spawn` rather than subscribed to here. It is deliberately not part of [`DnsView`]:
2022 /// it is not DNS data and it updates on its own cadence (a netmap can carry a new filter without
2023 /// a new DNS config, and the other way round).
2024 async fn on_start(
2025 (env, channel, filter_rx): Self::Args,
2026 slf: ActorRef<Self>,
2027 ) -> Result<Self, Self::Error> {
2028 env.subscribe::<Arc<ts_control::StateUpdate>>(&slf).await?;
2029 env.subscribe::<Arc<PeerState>>(&slf).await?;
2030 env.subscribe::<crate::route_updater::ActiveExitNode>(&slf)
2031 .await?;
2032
2033 // Seed the view with the runtime's IPv6 gate (default off) and the current accept-dns value.
2034 // Subsequent control/peer updates clone-and-modify this view: `enable_ipv6` (set once here)
2035 // is preserved, while `accept_dns` is re-read live from `Env` on every rebuild (it is
2036 // runtime-settable). The seed value is moot — no query is served before the first
2037 // StateUpdate — but seeding it keeps the pre-update view internally consistent.
2038 let (view_tx, view_rx) = watch::channel(Arc::new(DnsView {
2039 enable_ipv6: env.enable_ipv6,
2040 accept_dns: env.accept_dns(),
2041 ..DnsView::default()
2042 }));
2043
2044 let mut joinset = JoinSet::new();
2045
2046 // Bind the MagicDNS socket. If the bind fails we still start (fail closed: the actor just
2047 // never answers anything) so a transient bind error doesn't take down the runtime.
2048 let addr = SocketAddr::from((MAGIC_DNS_IP, MAGIC_DNS_PORT));
2049 match channel.udp_bind(addr).await {
2050 Ok(socket) => {
2051 tracing::debug!(%addr, "magic dns responder bound");
2052 joinset.spawn(serve(socket, view_rx.clone(), channel.clone()));
2053 }
2054 Err(e) => {
2055 tracing::error!(error = %e, %addr, "magic dns udp bind failed; responder inert");
2056 }
2057 }
2058
2059 // When this node advertises a peerAPI port, run the single peerAPI server on the same shared
2060 // view. It routes `/dns-query` to the exit-node DoH handler (recursive resolution gated by
2061 // `forward_exit_egress`, see `peerapi_doh`) and `/v0/put/<name>` to the Taildrop receive
2062 // handler when a store is configured (access-gated, fail-closed, see `peerapi`).
2063 if let Some(port) = env.peerapi_port {
2064 let channel = channel.clone();
2065 let view_rx = view_rx.clone();
2066 let forward_exit_egress = env.forward_exit_egress;
2067 let taildrop = env.taildrop_store.clone();
2068 let funnel_ingress = env.funnel_ingress.clone();
2069 joinset.spawn(crate::peerapi::serve(
2070 channel,
2071 port,
2072 view_rx,
2073 filter_rx,
2074 forward_exit_egress,
2075 taildrop,
2076 funnel_ingress,
2077 ));
2078 }
2079
2080 Ok(Self {
2081 _joinset: joinset,
2082 view_tx,
2083 env,
2084 channel,
2085 })
2086 }
2087}
2088
2089/// A bare SERVFAIL response header for a [`Query`] whose name could not be encoded into a
2090/// well-formed query (a non-ASCII label or an over-255-byte name). A 12-byte header with QR=1 (this
2091/// is a response) and RCODE=2 (server failure); no question or answer section (we never produced a
2092/// parseable question). Lets `query_dns` return a definite, honest RCODE instead of an empty buffer
2093/// that would read back as a fabricated NoError.
2094fn servfail_response() -> Vec<u8> {
2095 let mut resp = vec![0u8; 12];
2096 // Flags: QR=1 (byte 2, 0x80) + RCODE=2 (low nibble of byte 3). All other bits clear.
2097 resp[2] = 0x80;
2098 resp[3] = 0x02;
2099 resp
2100}
2101
2102impl Message<Query> for MagicDnsActor {
2103 type Reply = DnsQueryResult;
2104
2105 async fn handle(&mut self, query: Query, _ctx: &mut Context<Self, Self::Reply>) -> Self::Reply {
2106 // Synthesize a query packet and drive it through the SAME decide/forward path the serve loop
2107 // uses, against the freshest view — so the result and its anti-leak posture exactly match an
2108 // on-the-wire query. The id is fixed (0): a programmatic query has no concurrent-demux need,
2109 // and `response_matches_query` validates the echoed id against this same buffer.
2110 //
2111 // Normalize the name into labels: strip a single trailing dot (an FQDN's root marker — Go's
2112 // `dnsname.ToFQDN` does the same) and drop empty labels. An empty label would otherwise encode
2113 // as a lone `0x00`, identical to the QNAME root terminator, truncating the wire query and
2114 // corrupting the QTYPE/QCLASS that follow.
2115 let trimmed = query.name.strip_suffix('.').unwrap_or(&query.name);
2116 let labels: Vec<String> = trimmed
2117 .split('.')
2118 .filter(|label| !label.is_empty())
2119 .map(str::to_owned)
2120 .collect();
2121 let qtype = match query.qtype {
2122 1 => ts_dns_wire::QType::A,
2123 28 => ts_dns_wire::QType::Aaaa,
2124 12 => ts_dns_wire::QType::Ptr,
2125 other => ts_dns_wire::QType::Other(other),
2126 };
2127 // Class IN (1) — the only class the responder serves authoritatively (a non-IN class still
2128 // forwards via `forward_or_nodata`, matching the on-the-wire path).
2129 let buf = ts_dns_wire::encode_query(0, &ts_dns_wire::Name(labels), &qtype, 1);
2130
2131 let view = self.view_tx.borrow().clone();
2132
2133 let (response, resolvers_consulted) = match decide(&view, &buf) {
2134 // `decide` returns `None` only when `decode_query` rejects the buffer we just built. With
2135 // the name normalized above that can still happen for a name `encode_query` accepts but
2136 // `decode_query` rejects — a non-ASCII/IDN label (the caller must pass punycode) or a name
2137 // whose wire form exceeds 255 bytes. Surface a SERVFAIL (RCODE 2: "could not process")
2138 // rather than an empty buffer that would read back as a fabricated NoError. The serve loop
2139 // silently drops here (the on-wire client times out); a programmatic caller gets a
2140 // definite, honest error instead.
2141 None => (servfail_response(), Vec::new()),
2142 Some(Decision::Reply(resp)) => (
2143 check_response_size_and_set_tc(&buf, resp, ClientTransport::Udp),
2144 Vec::new(),
2145 ),
2146 Some(Decision::Forward {
2147 upstreams,
2148 query,
2149 servfail,
2150 recursive,
2151 }) => {
2152 let plan = forward_plan(&view, upstreams, recursive);
2153 match plan {
2154 RecursivePlan::Udp(upstreams) => {
2155 let resp = forward_query(
2156 &self.channel,
2157 &upstreams,
2158 &query,
2159 servfail,
2160 ClientTransport::Udp,
2161 view.upstream_tcp_retry(),
2162 )
2163 .await;
2164 (resp, upstreams)
2165 }
2166 RecursivePlan::Doh(doh_addr) => {
2167 let resp = crate::peerapi_doh::forward_doh(
2168 &self.channel,
2169 doh_addr,
2170 &query,
2171 servfail,
2172 ClientTransport::Udp,
2173 )
2174 .await;
2175 // The query egressed via the exit node's DoH endpoint, not a local UDP
2176 // upstream — report the DoH address as the resolver consulted.
2177 (resp, vec![doh_addr])
2178 }
2179 }
2180 }
2181 };
2182
2183 // RCODE is the low 4 bits of the second flags byte (header byte 3).
2184 let rcode = response_rcode(&response).unwrap_or(0);
2185
2186 DnsQueryResult {
2187 response,
2188 rcode,
2189 resolvers_consulted,
2190 }
2191 }
2192}
2193
2194impl Message<Arc<ts_control::StateUpdate>> for MagicDnsActor {
2195 type Reply = ();
2196
2197 async fn handle(
2198 &mut self,
2199 update: Arc<ts_control::StateUpdate>,
2200 _ctx: &mut Context<Self, Self::Reply>,
2201 ) {
2202 // Re-read the live accept-dns cell on every rebuild (it is runtime-settable via
2203 // `Device::set_accept_dns`); `enable_ipv6` is preserved from the seed (set once at spawn).
2204 let accept_dns = self.env.accept_dns();
2205 self.view_tx.send_modify(|view| {
2206 let mut next = (**view).clone();
2207 next.cfg = update.dns_config.clone().unwrap_or_default();
2208 next.self_node = update.node.clone();
2209 next.accept_dns = accept_dns;
2210 *view = Arc::new(next);
2211 });
2212 }
2213}
2214
2215impl Message<Arc<PeerState>> for MagicDnsActor {
2216 type Reply = ();
2217
2218 async fn handle(&mut self, state: Arc<PeerState>, _ctx: &mut Context<Self, Self::Reply>) {
2219 // Re-read the live accept-dns cell on every rebuild: `Device::set_accept_dns` triggers a
2220 // `RepublishState` that lands here, so this is the path that re-applies the gate after a
2221 // runtime toggle (covers the netstack responder AND the peerAPI DoH server sharing the view).
2222 let accept_dns = self.env.accept_dns();
2223 self.view_tx.send_modify(|view| {
2224 let mut next = (**view).clone();
2225 next.peers = Some(state.peers.clone());
2226 next.accept_dns = accept_dns;
2227 *view = Arc::new(next);
2228 });
2229 }
2230}
2231
2232impl Message<crate::route_updater::ActiveExitNode> for MagicDnsActor {
2233 type Reply = ();
2234
2235 async fn handle(
2236 &mut self,
2237 active: crate::route_updater::ActiveExitNode,
2238 _ctx: &mut Context<Self, Self::Reply>,
2239 ) {
2240 // Cache the active exit node's DoH endpoint so the serve loop delegates recursive queries
2241 // to it. `None` (no exit node, or one that can't proxy DNS) keeps recursion local. Resolving
2242 // the address here — once, from the route updater's authoritative selection — means the
2243 // serve loop never re-resolves the selector.
2244 let exit_doh = active.node.as_ref().and_then(|n| n.peerapi_doh_addr());
2245 self.view_tx.send_modify(|view| {
2246 let mut next = (**view).clone();
2247 next.exit_doh = exit_doh;
2248 *view = Arc::new(next);
2249 });
2250 }
2251}
2252
2253#[cfg(test)]
2254mod tests {
2255 use ts_control::{StableNodeId, TailnetAddress};
2256
2257 use super::*;
2258
2259 /// Test wrapper: run [`decide`] and extract the reply bytes. These tests configure no
2260 /// upstream resolvers, so an unresolved name fails closed to a `Reply` (NXDOMAIN), never a
2261 /// `Forward`; a `Forward` here is a bug and panics.
2262 fn answer(view: &DnsView, buf: &[u8]) -> Option<Vec<u8>> {
2263 match decide(view, buf)? {
2264 Decision::Reply(resp) => Some(resp),
2265 Decision::Forward { .. } => panic!("unexpected forward in authoritative-only test"),
2266 }
2267 }
2268
2269 /// Build a `Node` named `host.user.ts.net` with a known v4/v6 tailnet address.
2270 fn test_node() -> Node {
2271 Node {
2272 id: 1,
2273 stable_id: StableNodeId("n1".to_string()),
2274 hostname: "host".to_string(),
2275 user_id: 0,
2276 tailnet: Some("user.ts.net".to_string()),
2277 tags: vec![],
2278 addresses: vec![
2279 "100.64.0.1/32".parse().unwrap(),
2280 "fd7a::1/128".parse().unwrap(),
2281 ],
2282 tailnet_address: TailnetAddress {
2283 ipv4: "100.64.0.1/32".parse().unwrap(),
2284 ipv6: "fd7a::1/128".parse().unwrap(),
2285 },
2286 node_key: [0u8; 32].into(),
2287 node_key_expiry: None,
2288 expired: false,
2289 online: None,
2290 last_seen: None,
2291 key_signature: vec![],
2292 machine_key: None,
2293 disco_key: None,
2294 accepted_routes: vec![],
2295 underlay_addresses: vec![],
2296 derp_region: None,
2297 cap: Default::default(),
2298 cap_map: Default::default(),
2299 peerapi_port: None,
2300 peerapi_dns_proxy: false,
2301 is_wireguard_only: false,
2302 exit_node_dns_resolvers: vec![],
2303 peer_relay: false,
2304 ssh_host_keys: vec![],
2305 service_vips: Default::default(),
2306 unsigned_peer_api_only: false,
2307 }
2308 }
2309
2310 /// A view with MagicDNS on and a single peer in the db.
2311 fn view_with_peer() -> DnsView {
2312 let mut db = PeerDb::default();
2313 db.upsert(&test_node());
2314
2315 DnsView {
2316 cfg: DnsConfig {
2317 magic_dns: true,
2318 search_domains: vec!["user.ts.net".to_string()],
2319 ..Default::default()
2320 },
2321 peers: Some(Arc::new(db)),
2322 self_node: None,
2323 exit_doh: None,
2324 enable_ipv6: false,
2325 accept_dns: true,
2326 }
2327 }
2328
2329 /// Build a raw DNS query buffer for `labels` with the given id, qtype, qclass.
2330 fn build_query(id: u16, labels: &[&str], qtype: u16, qclass: u16) -> Vec<u8> {
2331 let mut buf: Vec<u8> = Vec::new();
2332 buf.extend_from_slice(&id.to_be_bytes());
2333 buf.extend_from_slice(&0u16.to_be_bytes()); // flags: QR=0 (query)
2334 buf.extend_from_slice(&1u16.to_be_bytes()); // QDCOUNT
2335 buf.extend_from_slice(&0u16.to_be_bytes()); // ANCOUNT
2336 buf.extend_from_slice(&0u16.to_be_bytes()); // NSCOUNT
2337 buf.extend_from_slice(&0u16.to_be_bytes()); // ARCOUNT
2338 for label in labels {
2339 buf.push(label.len() as u8);
2340 buf.extend_from_slice(label.as_bytes());
2341 }
2342 buf.push(0); // root label
2343 buf.extend_from_slice(&qtype.to_be_bytes());
2344 buf.extend_from_slice(&qclass.to_be_bytes());
2345 buf
2346 }
2347
2348 /// `build_query` plus an EDNS(0) OPT record in the additional section advertising `udp_size` as
2349 /// the requestor's UDP payload size (RFC 6891: root NAME, TYPE 41, CLASS = the size), in the
2350 /// only shape Go's `findOPTRecord` accepts: last record in the message, version 0, `RDLEN` 0.
2351 fn build_edns_query(
2352 id: u16,
2353 labels: &[&str],
2354 qtype: u16,
2355 qclass: u16,
2356 udp_size: u16,
2357 ) -> Vec<u8> {
2358 let mut buf = build_query(id, labels, qtype, qclass);
2359 buf[11] = 1; // ARCOUNT = 1
2360 buf.push(0); // NAME: root
2361 buf.extend_from_slice(&41u16.to_be_bytes()); // TYPE: OPT
2362 buf.extend_from_slice(&udp_size.to_be_bytes()); // CLASS: requestor's UDP payload size
2363 buf.extend_from_slice(&0u32.to_be_bytes()); // TTL: extended rcode + flags
2364 buf.extend_from_slice(&0u16.to_be_bytes()); // RDLENGTH: no options
2365 buf
2366 }
2367
2368 /// Like [`build_edns_query`] but with one EDNS option in the OPT record's RDATA, so `RDLEN` is
2369 /// non-zero — the shape a stub resolver sending a DNS cookie (option code 10) produces.
2370 fn build_edns_query_with_option(
2371 id: u16,
2372 labels: &[&str],
2373 qtype: u16,
2374 qclass: u16,
2375 udp_size: u16,
2376 option_code: u16,
2377 option_data: &[u8],
2378 ) -> Vec<u8> {
2379 let mut buf = build_edns_query(id, labels, qtype, qclass, udp_size);
2380 let rdata_len = 4 + option_data.len();
2381 let rdlength_at = buf.len() - 2;
2382 buf[rdlength_at..].copy_from_slice(&(rdata_len as u16).to_be_bytes());
2383 buf.extend_from_slice(&option_code.to_be_bytes());
2384 buf.extend_from_slice(&(option_data.len() as u16).to_be_bytes());
2385 buf.extend_from_slice(option_data);
2386 buf
2387 }
2388
2389 /// Parse a response header: returns `(id, rcode, ancount)`.
2390 fn parse_header(resp: &[u8]) -> (u16, u8, u16) {
2391 let id = u16::from_be_bytes([resp[0], resp[1]]);
2392 let flags = u16::from_be_bytes([resp[2], resp[3]]);
2393 let ancount = u16::from_be_bytes([resp[6], resp[7]]);
2394 (id, (flags & 0x000F) as u8, ancount)
2395 }
2396
2397 #[test]
2398 fn a_query_for_known_peer_answers_v4() {
2399 let view = view_with_peer();
2400 let buf = build_query(0x1234, &["host", "user", "ts", "net"], 1, 1);
2401
2402 let resp = answer(&view, &buf).expect("answers");
2403 let (id, rcode, ancount) = parse_header(&resp);
2404 assert_eq!(id, 0x1234);
2405 assert_eq!(rcode, 0, "NoError");
2406 assert_eq!(ancount, 1);
2407
2408 // The trailing RDATA of the single A record is the peer's tailnet v4 octets.
2409 let tail = &resp[resp.len() - 4..];
2410 assert_eq!(tail, &[100, 64, 0, 1]);
2411 }
2412
2413 #[test]
2414 fn aaaa_query_for_known_peer_is_nodata_when_ipv6_off() {
2415 // Gate OFF (default): an AAAA query for a known overlay peer must return NoError with an
2416 // empty answer (NODATA) — NOT the overlay v6 address, which the IPv4-only client can't
2417 // route. This is the anti-fingerprint / no-dead-connections posture.
2418 let view = view_with_peer();
2419 assert!(!view.enable_ipv6, "default gate is off");
2420 let buf = build_query(0x5, &["host", "user", "ts", "net"], 28, 1);
2421
2422 let resp = answer(&view, &buf).expect("answers");
2423 let (_, rcode, ancount) = parse_header(&resp);
2424 assert_eq!(rcode, 0, "NoError (NODATA)");
2425 assert_eq!(ancount, 0, "empty answer: no AAAA handed out with IPv6 off");
2426 }
2427
2428 #[test]
2429 fn a_query_still_resolves_when_ipv6_off() {
2430 // Gate OFF must not touch the A (v4) path: the v4 answer is byte-for-byte unchanged.
2431 let view = view_with_peer();
2432 let buf = build_query(0x6, &["host", "user", "ts", "net"], 1, 1);
2433
2434 let resp = answer(&view, &buf).expect("answers");
2435 let (_, rcode, ancount) = parse_header(&resp);
2436 assert_eq!(rcode, 0, "NoError");
2437 assert_eq!(ancount, 1);
2438 let tail = &resp[resp.len() - 4..];
2439 assert_eq!(tail, &[100, 64, 0, 1]);
2440 }
2441
2442 #[test]
2443 fn aaaa_query_for_known_peer_answers_v6_when_ipv6_on() {
2444 // Gate ON: historical behavior — answer AAAA from the overlay v6 address.
2445 let mut view = view_with_peer();
2446 view.enable_ipv6 = true;
2447 let buf = build_query(0x5, &["host", "user", "ts", "net"], 28, 1);
2448
2449 let resp = answer(&view, &buf).expect("answers");
2450 let (_, rcode, ancount) = parse_header(&resp);
2451 assert_eq!(rcode, 0, "NoError");
2452 assert_eq!(ancount, 1);
2453
2454 let expected = "fd7a::1".parse::<std::net::Ipv6Addr>().unwrap().octets();
2455 let tail = &resp[resp.len() - 16..];
2456 assert_eq!(tail, expected);
2457 }
2458
2459 #[test]
2460 fn aaaa_for_unknown_tailnet_name_is_nxdomain_not_forwarded_with_ipv6_off() {
2461 // Anti-leak, unchanged by the gate: an AAAA for a name under the tailnet suffix that has no
2462 // overlay match still fails closed to NXDOMAIN — never forwarded to a recursive upstream,
2463 // even with resolvers configured. (Gate OFF only changes the *positive* overlay match into
2464 // NODATA; a non-match still routes through `forward_or_nxdomain`.)
2465 let mut db = PeerDb::default();
2466 db.upsert(&test_node());
2467 let view = DnsView {
2468 cfg: DnsConfig {
2469 magic_dns: true,
2470 search_domains: vec!["user.ts.net".to_string()],
2471 fallback_resolvers: vec![DnsResolver {
2472 transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
2473 use_with_exit_node: false,
2474 }],
2475 ..Default::default()
2476 },
2477 peers: Some(Arc::new(db)),
2478 self_node: None,
2479 exit_doh: None,
2480 enable_ipv6: false,
2481 accept_dns: true,
2482 };
2483 let buf = build_query(0x5A, &["ghost", "user", "ts", "net"], 28, 1);
2484
2485 match decide(&view, &buf).expect("decides") {
2486 Decision::Reply(resp) => {
2487 let (_, rcode, _) = parse_header(&resp);
2488 assert_eq!(rcode, 3, "NxDomain: tailnet AAAA not leaked upstream");
2489 }
2490 Decision::Forward { .. } => panic!("tailnet AAAA must never be forwarded"),
2491 }
2492 }
2493
2494 #[test]
2495 fn bare_hostname_resolves() {
2496 // The name index also stores the bare hostname.
2497 let view = view_with_peer();
2498 let buf = build_query(0x7, &["host"], 1, 1);
2499
2500 let resp = answer(&view, &buf).expect("answers");
2501 let (_, rcode, ancount) = parse_header(&resp);
2502 assert_eq!(rcode, 0);
2503 assert_eq!(ancount, 1);
2504 }
2505
2506 #[test]
2507 fn unknown_off_tailnet_name_with_no_upstream_is_servfail() {
2508 // An off-tailnet name with no resolver configured cannot be forwarded. Go answers SERVFAIL
2509 // (a soft "couldn't resolve"), not NXDOMAIN — asserting non-existence of a real name we
2510 // simply have no upstream for would poison a downstream stub's negative cache. (A *tailnet*
2511 // name with no overlay match stays NXDOMAIN — see `tailnet_name_is_never_forwarded` — and a
2512 // negative split-DNS route stays NXDOMAIN — see `negative_route_is_nxdomain_not_forwarded`.)
2513 let view = view_with_peer();
2514 let buf = build_query(0x9, &["nope", "example", "com"], 1, 1);
2515
2516 let resp = answer(&view, &buf).expect("answers");
2517 let (_, rcode, ancount) = parse_header(&resp);
2518 assert_eq!(
2519 rcode, 2,
2520 "ServFail: off-tailnet name, nothing to forward to"
2521 );
2522 assert_eq!(ancount, 0);
2523 }
2524
2525 #[test]
2526 fn magic_dns_off_is_refused() {
2527 // Fail closed: with MagicDNS disabled, even a known name is refused.
2528 let mut view = view_with_peer();
2529 view.cfg.magic_dns = false;
2530 let buf = build_query(0xAB, &["host", "user", "ts", "net"], 1, 1);
2531
2532 let resp = answer(&view, &buf).expect("answers");
2533 let (_, rcode, ancount) = parse_header(&resp);
2534 assert_eq!(rcode, 5, "Refused");
2535 assert_eq!(ancount, 0);
2536 }
2537
2538 #[test]
2539 fn accept_dns_false_refuses_otherwise_answerable_query() {
2540 // The accept-dns gate (Go `CorpDNS`): with `accept_dns == false` the node ignores the
2541 // tailnet DNS config, so even a known peer name that would normally answer authoritatively is
2542 // REFUSED (the responder serves nothing) — mirroring Go applying an empty `dns.Config`.
2543 let mut view = view_with_peer();
2544 assert!(view.cfg.magic_dns, "MagicDNS itself is on");
2545 view.accept_dns = false;
2546 let buf = build_query(0xDD, &["host", "user", "ts", "net"], 1, 1);
2547
2548 let resp = answer(&view, &buf).expect("answers");
2549 let (_, rcode, ancount) = parse_header(&resp);
2550 assert_eq!(rcode, 5, "Refused: accept_dns off ⇒ serve nothing");
2551 assert_eq!(ancount, 0);
2552
2553 // Flip accept_dns back ON (the config was never destroyed, only gated): the same query now
2554 // answers authoritatively — proving the OFF→ON restore is automatic.
2555 view.accept_dns = true;
2556 let resp = answer(&view, &buf).expect("answers");
2557 let (_, rcode, ancount) = parse_header(&resp);
2558 assert_eq!(rcode, 0, "NoError: accept_dns on ⇒ the known peer answers");
2559 assert_eq!(ancount, 1);
2560 let tail = &resp[resp.len() - 4..];
2561 assert_eq!(tail, &[100, 64, 0, 1], "the peer's tailnet v4 is served");
2562 }
2563
2564 #[test]
2565 fn default_view_serves_nothing() {
2566 // The default (no dns_config seen) has magic_dns == false: fail closed.
2567 let view = DnsView::default();
2568 let buf = build_query(0x1, &["host", "user", "ts", "net"], 1, 1);
2569
2570 let resp = answer(&view, &buf).expect("answers");
2571 let (_, rcode, _) = parse_header(&resp);
2572 assert_eq!(rcode, 5, "Refused");
2573 }
2574
2575 #[test]
2576 fn unsupported_qtype_on_tailnet_name_is_nodata_not_refused() {
2577 // TXT (type 16) for a tailnet-authoritative name: the name exists but we hold no TXT, so —
2578 // like Go — return NODATA (empty NOERROR), NOT REFUSED (which would make a stub abandon the
2579 // resolver) and NOT NXDOMAIN (the name exists). The name is never forwarded (anti-leak).
2580 let view = view_with_peer();
2581 let buf = build_query(0x1, &["host", "user", "ts", "net"], 16, 1);
2582
2583 let resp = answer(&view, &buf).expect("answers");
2584 let (_, rcode, ancount) = parse_header(&resp);
2585 assert_eq!(rcode, 0, "NoError (NODATA), not Refused");
2586 assert_eq!(ancount, 0, "no answer records (NODATA)");
2587 }
2588
2589 #[test]
2590 fn unsupported_qtype_off_tailnet_forwards_or_servfails() {
2591 // A non-A/AAAA/PTR qtype for an OFF-tailnet name must be forwardable like A/AAAA — never
2592 // REFUSED. With no upstream configured in this view it soft-fails to SERVFAIL (the same
2593 // disposition an off-tailnet A query gets here), proving the qtype no longer short-circuits
2594 // to REFUSED. HTTPS/SVCB is type 65 (the browser HTTP/3 + ECH case the old REFUSED broke).
2595 let view = view_with_peer();
2596 let buf = build_query(0x1, &["example", "com"], 65, 1);
2597
2598 let resp = answer(&view, &buf).expect("answers");
2599 let (_, rcode, _) = parse_header(&resp);
2600 assert_eq!(
2601 rcode, 2,
2602 "off-tailnet, no upstream -> SERVFAIL (forwardable, not Refused)"
2603 );
2604 }
2605
2606 #[test]
2607 fn unimplemented_qtype_on_tailnet_name_is_notimp() {
2608 // NS (2), SOA (6), HINFO (13), AXFR (252) for a tailnet-authoritative name must answer NOTIMP
2609 // (rcode 4), matching Go `resolveLocal`'s `case dns.TypeNS, dns.TypeSOA, dns.TypeAXFR,
2610 // dns.TypeHINFO: return RCodeNotImplemented`. Returning NODATA (rcode 0) here was a clean
2611 // fingerprint (a `dig SOA user.ts.net` answer differs from real tailscaled). The name is
2612 // still never forwarded (anti-leak).
2613 let view = view_with_peer();
2614 for qtype in [2u16, 6, 13, 252] {
2615 let buf = build_query(0x1, &["host", "user", "ts", "net"], qtype, 1);
2616 let resp = answer(&view, &buf).expect("answers");
2617 let (_, rcode, ancount) = parse_header(&resp);
2618 assert_eq!(rcode, 4, "qtype {qtype} on a tailnet name must be NOTIMP");
2619 assert_eq!(ancount, 0, "NOTIMP carries no answer records");
2620 }
2621 }
2622
2623 #[test]
2624 fn unimplemented_qtype_off_tailnet_still_forwards_not_notimp() {
2625 // The NOTIMP disposition is ONLY for a name we are authoritative for. An NS query for an
2626 // off-tailnet name must still forward (here: SERVFAIL, no upstream) — NOT NOTIMP — exactly
2627 // like the off-tailnet HTTPS/SVCB case above. Guards the NOTIMP change against over-reach.
2628 let view = view_with_peer();
2629 let buf = build_query(0x1, &["example", "com"], 2, 1); // NS, off-tailnet
2630 let resp = answer(&view, &buf).expect("answers");
2631 let (_, rcode, _) = parse_header(&resp);
2632 assert_eq!(
2633 rcode, 2,
2634 "off-tailnet NS -> SERVFAIL (forwardable), not NOTIMP"
2635 );
2636 }
2637
2638 #[test]
2639 fn malformed_query_is_dropped() {
2640 // A response (QR bit set) is not a query; we drop it (no answer).
2641 let mut buf = build_query(0x1, &["host"], 1, 1);
2642 buf[2] = 0x80; // set QR bit
2643 assert!(answer(&view_with_peer(), &buf).is_none());
2644 }
2645
2646 #[test]
2647 fn ptr_for_known_ip_answers_fqdn() {
2648 let view = view_with_peer();
2649 // Reverse name for 100.64.0.1 => 1.0.64.100.in-addr.arpa
2650 let buf = build_query(0x33, &["1", "0", "64", "100", "in-addr", "arpa"], 12, 1);
2651
2652 let resp = answer(&view, &buf).expect("answers");
2653 let (_, rcode, ancount) = parse_header(&resp);
2654 assert_eq!(rcode, 0, "NoError");
2655 assert_eq!(ancount, 1);
2656
2657 // The PTR rdata encodes the peer's fqdn "host.user.ts.net" as length-prefixed labels.
2658 let expected = {
2659 let mut out = Vec::new();
2660 for label in ["host", "user", "ts", "net"] {
2661 out.push(label.len() as u8);
2662 out.extend_from_slice(label.as_bytes());
2663 }
2664 out.push(0);
2665 out
2666 };
2667 let tail = &resp[resp.len() - expected.len()..];
2668 assert_eq!(tail, expected.as_slice());
2669 }
2670
2671 #[test]
2672 fn ptr_for_unknown_public_ip_off_tailnet_is_servfail() {
2673 let view = view_with_peer();
2674 // 9.9.9.9 is a public IP, not a known tailnet IP and not in the CGNAT reverse zone — so its
2675 // reverse query is an ordinary off-tailnet name. With no upstream to forward it to, that is
2676 // SERVFAIL (soft), not NXDOMAIN. (A CGNAT/ip6.arpa reverse for an unmatched tailnet IP still
2677 // fails closed to NXDOMAIN as an anti-leak guard — see `ptr_for_unknown_tailnet_ip_*`.)
2678 let buf = build_query(0x34, &["9", "9", "9", "9", "in-addr", "arpa"], 12, 1);
2679
2680 let resp = answer(&view, &buf).expect("answers");
2681 let (_, rcode, _) = parse_header(&resp);
2682 assert_eq!(
2683 rcode, 2,
2684 "ServFail: off-tailnet public-IP reverse, no upstream"
2685 );
2686 }
2687
2688 #[test]
2689 fn ptr_for_unknown_tailnet_ip_is_nxdomain_not_forwarded() {
2690 // A view WITH an upstream resolver: an off-tailnet reverse query would forward, but a
2691 // reverse query for an unmatched IP in the CGNAT range (100.64.0.0/10) must fail closed to
2692 // NXDOMAIN — the probed tailnet IP must never leak upstream.
2693 let mut db = PeerDb::default();
2694 db.upsert(&test_node());
2695 let view = DnsView {
2696 cfg: DnsConfig {
2697 magic_dns: true,
2698 search_domains: vec!["user.ts.net".to_string()],
2699 fallback_resolvers: vec![DnsResolver {
2700 transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
2701 use_with_exit_node: false,
2702 }],
2703 ..Default::default()
2704 },
2705 peers: Some(Arc::new(db)),
2706 self_node: None,
2707 exit_doh: None,
2708 enable_ipv6: false,
2709 accept_dns: true,
2710 };
2711
2712 // 100.64.0.9 is in CGNAT range but owned by no peer => NXDOMAIN, never a Forward.
2713 let buf = build_query(0x35, &["9", "0", "64", "100", "in-addr", "arpa"], 12, 1);
2714 match decide(&view, &buf).expect("decides") {
2715 Decision::Reply(resp) => {
2716 let (_, rcode, _) = parse_header(&resp);
2717 assert_eq!(rcode, 3, "NxDomain");
2718 }
2719 Decision::Forward { .. } => {
2720 panic!("tailnet CGNAT PTR must never be forwarded upstream")
2721 }
2722 }
2723 }
2724
2725 /// Anti-leak regression for the exotic-qtype forward path: a NON-PTR query (TXT, type 16) for a
2726 /// tailnet CGNAT reverse name, with an upstream configured, must STILL fail closed to NXDOMAIN —
2727 /// never forward. The PTR arm guards this, but the `QType::Other` path routes through
2728 /// `forward_or_nodata`, which must re-apply the reverse-zone guard or the tailnet IP leaks.
2729 #[test]
2730 fn exotic_qtype_for_tailnet_cgnat_reverse_is_nxdomain_not_forwarded() {
2731 let mut db = PeerDb::default();
2732 db.upsert(&test_node());
2733 let view = DnsView {
2734 cfg: DnsConfig {
2735 magic_dns: true,
2736 search_domains: vec!["user.ts.net".to_string()],
2737 fallback_resolvers: vec![DnsResolver {
2738 transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
2739 use_with_exit_node: false,
2740 }],
2741 ..Default::default()
2742 },
2743 peers: Some(Arc::new(db)),
2744 self_node: None,
2745 exit_doh: None,
2746 enable_ipv6: false,
2747 accept_dns: true,
2748 };
2749
2750 // TXT (16) for a CGNAT reverse name => NXDOMAIN, never a Forward (no tailnet-IP leak).
2751 let buf = build_query(0x36, &["9", "0", "64", "100", "in-addr", "arpa"], 16, 1);
2752 match decide(&view, &buf).expect("decides") {
2753 Decision::Reply(resp) => {
2754 let (_, rcode, _) = parse_header(&resp);
2755 assert_eq!(rcode, 3, "NxDomain");
2756 }
2757 Decision::Forward { .. } => {
2758 panic!("a non-PTR query for a tailnet CGNAT reverse name must never forward")
2759 }
2760 }
2761 }
2762
2763 /// Same anti-leak guard for an `ip6.arpa` reverse name under an exotic qtype: must NXDOMAIN, not
2764 /// forward (revealing a tailnet ULA was probed).
2765 #[test]
2766 fn exotic_qtype_for_ip6_arpa_is_nxdomain_not_forwarded() {
2767 let view = view_with_routes(
2768 std::collections::BTreeMap::new(),
2769 vec![udp("9.9.9.9:53")],
2770 vec![],
2771 );
2772 // An ip6.arpa reverse name with a TXT (16) qtype must fail closed.
2773 let buf = build_query(
2774 0x37,
2775 &[
2776 "1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0",
2777 "a", "7", "d", "f", "ip6", "arpa",
2778 ],
2779 16,
2780 1,
2781 );
2782 match decide(&view, &buf).expect("decides") {
2783 Decision::Reply(resp) => {
2784 let (_, rcode, _) = parse_header(&resp);
2785 assert_eq!(rcode, 3, "NxDomain");
2786 }
2787 Decision::Forward { .. } => panic!("an ip6.arpa exotic-qtype query must never forward"),
2788 }
2789 }
2790
2791 #[test]
2792 fn is_tailnet_cgnat_classifies_range() {
2793 assert!(is_tailnet_cgnat("100.64.0.0".parse().unwrap()));
2794 assert!(is_tailnet_cgnat("100.64.0.1".parse().unwrap()));
2795 assert!(is_tailnet_cgnat("100.127.255.255".parse().unwrap()));
2796 // Outside the /10:
2797 assert!(!is_tailnet_cgnat("100.63.255.255".parse().unwrap()));
2798 assert!(!is_tailnet_cgnat("100.128.0.0".parse().unwrap()));
2799 assert!(!is_tailnet_cgnat("9.9.9.9".parse().unwrap()));
2800 // The MagicDNS resolver IP 100.100.100.100 is itself inside the /10.
2801 assert!(is_tailnet_cgnat("100.100.100.100".parse().unwrap()));
2802 }
2803
2804 #[test]
2805 fn response_matches_query_validates_id_and_qr() {
2806 // query id 0x1234, QR=0
2807 let query = build_query(0x1234, &["a", "com"], 1, 1);
2808
2809 // A well-formed response: same id, QR=1.
2810 let mut good = query.clone();
2811 good[2] |= 0x80;
2812 assert!(response_matches_query(&query, &good));
2813
2814 // Same id but QR still 0 (not a response): rejected.
2815 assert!(!response_matches_query(&query, &query));
2816
2817 // QR=1 but a different transaction id: rejected (off-path forgery).
2818 let mut wrong_id = good.clone();
2819 wrong_id[0] ^= 0xFF;
2820 assert!(!response_matches_query(&query, &wrong_id));
2821
2822 // Too-short buffers: rejected.
2823 assert!(!response_matches_query(&query, &[0u8; 2]));
2824 assert!(!response_matches_query(&[0u8; 3], &good));
2825 }
2826
2827 #[test]
2828 fn self_node_resolves_when_no_peer_match() {
2829 // With the peer db empty but a self node set, the self node answers for its own name.
2830 let view = DnsView {
2831 cfg: DnsConfig {
2832 magic_dns: true,
2833 search_domains: vec![],
2834 ..Default::default()
2835 },
2836 peers: None,
2837 self_node: Some(test_node()),
2838 exit_doh: None,
2839 enable_ipv6: false,
2840 accept_dns: true,
2841 };
2842 let buf = build_query(0x44, &["host", "user", "ts", "net"], 1, 1);
2843
2844 let resp = answer(&view, &buf).expect("answers");
2845 let (_, rcode, ancount) = parse_header(&resp);
2846 assert_eq!(rcode, 0);
2847 assert_eq!(ancount, 1);
2848 let tail = &resp[resp.len() - 4..];
2849 assert_eq!(tail, &[100, 64, 0, 1]);
2850 }
2851
2852 #[test]
2853 fn partially_qualified_name_resolves_via_search_domain() {
2854 // "host.user" is not indexed directly, but the "user.ts.net" search domain qualifies it
2855 // to "host.user.user.ts.net"... which does NOT match. The realistic case is "host" (bare,
2856 // already indexed) and "host.user.ts.net" (fqdn). Verify a name needing suffix expansion:
2857 // with search domain "ts.net" the partially-qualified "host.user" => "host.user.ts.net".
2858 let mut view = view_with_peer();
2859 view.cfg.search_domains = vec!["ts.net".to_string()];
2860 let buf = build_query(0x55, &["host", "user"], 1, 1);
2861
2862 let resp = answer(&view, &buf).expect("answers");
2863 let (_, rcode, ancount) = parse_header(&resp);
2864 assert_eq!(rcode, 0, "NoError via search-domain expansion");
2865 assert_eq!(ancount, 1);
2866 let tail = &resp[resp.len() - 4..];
2867 assert_eq!(tail, &[100, 64, 0, 1]);
2868 }
2869
2870 #[test]
2871 fn extra_record_a_answers_when_no_peer_match() {
2872 // A control-pushed static A record answers for a non-peer name, fail-closed otherwise.
2873 let mut view = view_with_peer();
2874 view.cfg.extra_records = vec![ts_control::ExtraRecord {
2875 name: "static.user.ts.net".to_string(),
2876 addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2877 }];
2878 let buf = build_query(0x77, &["static", "user", "ts", "net"], 1, 1);
2879
2880 let resp = answer(&view, &buf).expect("answers");
2881 let (_, rcode, ancount) = parse_header(&resp);
2882 assert_eq!(rcode, 0, "NoError from extra record");
2883 assert_eq!(ancount, 1);
2884 let tail = &resp[resp.len() - 4..];
2885 assert_eq!(tail, &[100, 64, 0, 9]);
2886 }
2887
2888 #[test]
2889 fn extra_record_matches_query_case_insensitively() {
2890 // The query name is canonicalized (lowercased) at decode time, so a mixed-case query
2891 // matches a lowercase extra record.
2892 let mut view = view_with_peer();
2893 view.cfg.extra_records = vec![ts_control::ExtraRecord {
2894 name: "static.user.ts.net".to_string(),
2895 addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2896 }];
2897 let buf = build_query(0x7A, &["Static", "User", "TS", "net"], 1, 1);
2898
2899 let resp = answer(&view, &buf).expect("answers");
2900 let (_, rcode, ancount) = parse_header(&resp);
2901 assert_eq!(rcode, 0, "NoError: case-insensitive match");
2902 assert_eq!(ancount, 1);
2903 let tail = &resp[resp.len() - 4..];
2904 assert_eq!(tail, &[100, 64, 0, 9]);
2905 }
2906
2907 #[test]
2908 fn extra_record_not_expanded_by_search_domain() {
2909 // Unlike peer names, an extra record is matched as an FQDN only: a bare query that would
2910 // need search-domain expansion to reach the record name must NOT resolve.
2911 let mut view = view_with_peer();
2912 view.cfg.extra_records = vec![ts_control::ExtraRecord {
2913 name: "static.user.ts.net".to_string(),
2914 addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2915 }];
2916 // "static" would only reach "static.user.ts.net" via the "user.ts.net" search domain.
2917 let buf = build_query(0x7B, &["static"], 1, 1);
2918
2919 let resp = answer(&view, &buf).expect("answers");
2920 let (_, rcode, _) = parse_header(&resp);
2921 // Not search-expanded → treated as the bare off-tailnet name "static", which has no upstream
2922 // here, so SERVFAIL (soft). The point of the test — that the extra record is NOT reachable
2923 // via search expansion — holds regardless of the failure rcode.
2924 assert_eq!(
2925 rcode, 2,
2926 "ServFail: bare 'static' is not search-expanded to the extra record"
2927 );
2928 }
2929
2930 #[test]
2931 fn extra_record_aaaa_family_is_isolated() {
2932 // An A-only extra record must NOT answer an AAAA query for the same name (NxDomain).
2933 let mut view = view_with_peer();
2934 view.cfg.extra_records = vec![ts_control::ExtraRecord {
2935 name: "v4only.user.ts.net".to_string(),
2936 addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2937 }];
2938 let buf = build_query(0x78, &["v4only", "user", "ts", "net"], 28, 1);
2939
2940 let resp = answer(&view, &buf).expect("answers");
2941 let (_, rcode, _) = parse_header(&resp);
2942 assert_eq!(rcode, 3, "NxDomain: A record does not satisfy AAAA");
2943 }
2944
2945 #[test]
2946 fn extra_record_ignored_when_magic_dns_off() {
2947 // Fail closed: extra records are never served while MagicDNS is disabled.
2948 let mut view = view_with_peer();
2949 view.cfg.magic_dns = false;
2950 view.cfg.extra_records = vec![ts_control::ExtraRecord {
2951 name: "static.user.ts.net".to_string(),
2952 addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2953 }];
2954 let buf = build_query(0x79, &["static", "user", "ts", "net"], 1, 1);
2955
2956 let resp = answer(&view, &buf).expect("answers");
2957 let (_, rcode, _) = parse_header(&resp);
2958 assert_eq!(rcode, 5, "Refused");
2959 }
2960
2961 /// The node attribute control sets to make every subdomain of a node resolve to it (Go
2962 /// `tailcfg/nodecap`'s `NodeAttrDNSSubdomainResolve`).
2963 const DNS_SUBDOMAIN_RESOLVE: &str = "dns-subdomain-resolve";
2964
2965 /// A view holding a single peer `host.user.ts.net` that carries the `dns-subdomain-resolve`
2966 /// node attribute, so control has declared every name under it to resolve to its addresses.
2967 fn view_with_subdomain_host() -> DnsView {
2968 let mut node = test_node();
2969 node.cap_map
2970 .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
2971
2972 let mut db = PeerDb::default();
2973 db.upsert(&node);
2974
2975 let mut view = view_with_peer();
2976 view.peers = Some(Arc::new(db));
2977 view
2978 }
2979
2980 #[test]
2981 fn subdomain_of_a_subdomain_host_resolves_to_it() {
2982 // `my.host.user.ts.net` has no record of its own; its parent `host.user.ts.net` carries the
2983 // attribute, so it answers with the parent's address.
2984 let view = view_with_subdomain_host();
2985 let buf = build_query(0x90, &["my", "host", "user", "ts", "net"], 1, 1);
2986
2987 let resp = answer(&view, &buf).expect("answers");
2988 let (_, rcode, ancount) = parse_header(&resp);
2989 assert_eq!(rcode, 0, "NoError from the subdomain host");
2990 assert_eq!(ancount, 1);
2991 assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
2992 }
2993
2994 #[test]
2995 fn a_multi_label_subdomain_of_a_subdomain_host_resolves() {
2996 // The walk climbs every parent, not one level: `be.my.host` reaches `host` just as
2997 // `my.host` does. One level of parent is not what upstream implements.
2998 let view = view_with_subdomain_host();
2999 let buf = build_query(0x91, &["be", "my", "host", "user", "ts", "net"], 1, 1);
3000
3001 let resp = answer(&view, &buf).expect("answers");
3002 let (_, rcode, ancount) = parse_header(&resp);
3003 assert_eq!(rcode, 0, "NoError: the walk is not depth-limited");
3004 assert_eq!(ancount, 1);
3005 assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
3006 }
3007
3008 #[test]
3009 fn subdomain_of_a_peer_without_the_attribute_is_nxdomain() {
3010 // The attribute is what turns the walk on. Without it — the default for every node — a
3011 // subdomain of a peer name is still authoritatively absent.
3012 let view = view_with_peer();
3013 assert!(
3014 !view
3015 .node_by_name("host.user.ts.net")
3016 .expect("peer is present")
3017 .resolves_subdomains(),
3018 "the plain test peer carries no node attribute"
3019 );
3020 let buf = build_query(0x92, &["my", "host", "user", "ts", "net"], 1, 1);
3021
3022 let resp = answer(&view, &buf).expect("answers");
3023 let (_, rcode, ancount) = parse_header(&resp);
3024 assert_eq!(rcode, 3, "NxDomain: no attribute, no subdomain resolution");
3025 assert_eq!(ancount, 0);
3026 }
3027
3028 #[test]
3029 fn an_exact_match_beats_the_subdomain_host() {
3030 // The walk is the *miss* path: a name that resolves exactly — here a control-pushed extra
3031 // record — keeps its own answer, and never takes the parent's.
3032 let mut view = view_with_subdomain_host();
3033 view.cfg.extra_records = vec![ts_control::ExtraRecord {
3034 name: "my.host.user.ts.net".to_string(),
3035 addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
3036 }];
3037 let buf = build_query(0x93, &["my", "host", "user", "ts", "net"], 1, 1);
3038
3039 let resp = answer(&view, &buf).expect("answers");
3040 let (_, rcode, ancount) = parse_header(&resp);
3041 assert_eq!(rcode, 0, "NoError");
3042 assert_eq!(ancount, 1);
3043 assert_eq!(
3044 &resp[resp.len() - 4..],
3045 &[100, 64, 0, 9],
3046 "the exact record answers, not the subdomain host's address"
3047 );
3048 }
3049
3050 #[test]
3051 fn the_subdomain_walk_stops_at_the_tailnet_zone() {
3052 // A node whose own FQDN *is* the search domain must not make the whole zone a wildcard:
3053 // the walk stops at the zone apex rather than climbing into names we do not serve.
3054 let mut zone_node = test_node();
3055 zone_node.hostname = "user".to_string();
3056 zone_node.tailnet = Some("ts.net".to_string());
3057 zone_node
3058 .cap_map
3059 .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
3060 assert_eq!(zone_node.fqdn(false), "user.ts.net", "the zone apex itself");
3061
3062 let mut db = PeerDb::default();
3063 db.upsert(&zone_node);
3064 let mut view = view_with_peer();
3065 view.peers = Some(Arc::new(db));
3066
3067 for labels in [
3068 ["nothing", "user", "ts", "net"].as_slice(),
3069 ["deeper", "nothing", "user", "ts", "net"].as_slice(),
3070 ] {
3071 let buf = build_query(0x94, labels, 1, 1);
3072 let resp = answer(&view, &buf).expect("answers");
3073 let (_, rcode, ancount) = parse_header(&resp);
3074 assert_eq!(rcode, 3, "NxDomain: the walk stopped at {:?}", labels);
3075 assert_eq!(ancount, 0);
3076 }
3077 }
3078
3079 #[test]
3080 fn the_subdomain_walk_does_not_search_expand_a_bare_label() {
3081 // The peer-name index also holds bare hostnames, so a peer named after a public suffix must
3082 // not swallow every name under it: only a fully-qualified parent is a walk candidate. Go
3083 // cannot do this at all — its resolver does no search-list expansion.
3084 let mut suffix_node = test_node();
3085 suffix_node.hostname = "com".to_string();
3086 suffix_node
3087 .cap_map
3088 .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
3089
3090 let mut db = PeerDb::default();
3091 db.upsert(&suffix_node);
3092 let mut view = view_with_peer();
3093 view.peers = Some(Arc::new(db));
3094
3095 let buf = build_query(0x95, &["www", "example", "com"], 1, 1);
3096 let resp = answer(&view, &buf).expect("answers");
3097 let (_, rcode, ancount) = parse_header(&resp);
3098 assert_eq!(
3099 rcode, 2,
3100 "ServFail: an off-tailnet name with no upstream, NOT the peer named 'com'"
3101 );
3102 assert_eq!(ancount, 0, "no answer manufactured from a bare hostname");
3103
3104 // The qualified form of the same peer still resolves its subdomains: the bound rejects the
3105 // bare label, not the subdomain host.
3106 let buf = build_query(0x96, &["www", "com", "user", "ts", "net"], 1, 1);
3107 let resp = answer(&view, &buf).expect("answers");
3108 let (_, rcode, ancount) = parse_header(&resp);
3109 assert_eq!(rcode, 0, "NoError from com.user.ts.net");
3110 assert_eq!(ancount, 1);
3111 assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
3112 }
3113
3114 #[test]
3115 fn aaaa_for_a_subdomain_host_follows_the_ipv6_gate() {
3116 // The subdomain answer is the parent node's address, so it takes the same AAAA gate an
3117 // exact peer match does: NODATA with IPv6 off, the overlay v6 with it on.
3118 let mut view = view_with_subdomain_host();
3119 let buf = build_query(0x97, &["my", "host", "user", "ts", "net"], 28, 1);
3120
3121 let resp = answer(&view, &buf).expect("answers");
3122 let (_, rcode, ancount) = parse_header(&resp);
3123 assert_eq!(rcode, 0, "NoError (NODATA) with the gate off");
3124 assert_eq!(ancount, 0);
3125
3126 view.enable_ipv6 = true;
3127 let resp = answer(&view, &buf).expect("answers");
3128 let (_, rcode, ancount) = parse_header(&resp);
3129 assert_eq!(rcode, 0, "NoError");
3130 assert_eq!(ancount, 1);
3131 let expected = "fd7a::1".parse::<std::net::Ipv6Addr>().unwrap().octets();
3132 assert_eq!(&resp[resp.len() - 16..], expected);
3133 }
3134
3135 #[test]
3136 fn a_subdomain_of_the_self_node_resolves_when_it_has_the_attribute() {
3137 // The walk runs over the same name lookup the exact match uses, so the self node is a
3138 // subdomain host too when control sets the attribute on it.
3139 let mut self_node = test_node();
3140 self_node.hostname = "me".to_string();
3141 self_node
3142 .cap_map
3143 .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
3144
3145 let mut view = view_with_peer();
3146 view.peers = None;
3147 view.self_node = Some(self_node);
3148
3149 let buf = build_query(0x98, &["a", "b", "me", "user", "ts", "net"], 1, 1);
3150 let resp = answer(&view, &buf).expect("answers");
3151 let (_, rcode, ancount) = parse_header(&resp);
3152 assert_eq!(rcode, 0, "NoError from the self node");
3153 assert_eq!(ancount, 1);
3154 assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
3155 }
3156
3157 #[test]
3158 fn non_in_class_on_tailnet_name_is_nodata_not_answered_as_in() {
3159 // A CHAOS-class (3) query for a tailnet name must NOT be answered as IN (no overlay A), and
3160 // must NOT be REFUSED (Go does no class check on the local path). It's an unsupported
3161 // authoritative class -> NODATA (empty NOERROR), and never forwarded (tailnet name).
3162 let view = view_with_peer();
3163 let buf = build_query(0x66, &["host", "user", "ts", "net"], 1, 3);
3164
3165 let resp = answer(&view, &buf).expect("answers");
3166 let (_, rcode, ancount) = parse_header(&resp);
3167 assert_eq!(
3168 rcode, 0,
3169 "NoError (NODATA), not Refused and not an IN answer"
3170 );
3171 assert_eq!(
3172 ancount, 0,
3173 "must not hand out the overlay A for a non-IN class"
3174 );
3175 }
3176
3177 #[test]
3178 fn non_in_class_off_tailnet_forwards_or_servfails() {
3179 // A non-IN class for an OFF-tailnet name is forwardable (Go forwards it), never REFUSED.
3180 // No upstream here -> SERVFAIL, proving the class gate no longer short-circuits to Refused.
3181 let view = view_with_peer();
3182 let buf = build_query(0x66, &["example", "com"], 1, 3);
3183
3184 let resp = answer(&view, &buf).expect("answers");
3185 let (_, rcode, _) = parse_header(&resp);
3186 assert_eq!(
3187 rcode, 2,
3188 "off-tailnet non-IN class, no upstream -> SERVFAIL, not Refused"
3189 );
3190 }
3191
3192 /// A view with MagicDNS on, the `user.ts.net` search domain, and the given split-DNS routes
3193 /// + global resolvers.
3194 fn view_with_routes(
3195 routes: std::collections::BTreeMap<String, Vec<DnsResolver>>,
3196 resolvers: Vec<DnsResolver>,
3197 fallback: Vec<DnsResolver>,
3198 ) -> DnsView {
3199 DnsView {
3200 cfg: DnsConfig {
3201 magic_dns: true,
3202 search_domains: vec!["user.ts.net".to_string()],
3203 routes,
3204 resolvers,
3205 fallback_resolvers: fallback,
3206 ..Default::default()
3207 },
3208 peers: None,
3209 self_node: None,
3210 exit_doh: None,
3211 enable_ipv6: false,
3212 accept_dns: true,
3213 }
3214 }
3215
3216 fn udp(addr: &str) -> DnsResolver {
3217 DnsResolver {
3218 transport: ts_control::ResolverTransport::Udp(addr.parse().unwrap()),
3219 use_with_exit_node: false,
3220 }
3221 }
3222
3223 #[test]
3224 fn split_dns_route_forwards_to_matching_upstream() {
3225 let mut routes = std::collections::BTreeMap::new();
3226 routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
3227 let view = view_with_routes(routes, vec![], vec![]);
3228 let buf = build_query(0x100, &["api", "corp", "example"], 1, 1);
3229
3230 match decide(&view, &buf).expect("decides") {
3231 Decision::Forward { upstreams, .. } => {
3232 assert_eq!(upstreams, vec!["10.0.0.53:53".parse().unwrap()]);
3233 }
3234 Decision::Reply(_) => panic!("expected forward to the split-DNS upstream"),
3235 }
3236 }
3237
3238 #[test]
3239 fn exotic_qtype_off_tailnet_forwards_to_upstream() {
3240 // The core of the fix: an HTTPS/SVCB (type 65) query for an off-tailnet name with a matching
3241 // route must FORWARD to the upstream (verbatim), exactly like an A query would — not REFUSE
3242 // and not NXDOMAIN. This is the browser HTTP/3 + ECH case the old blanket-REFUSE broke.
3243 let mut routes = std::collections::BTreeMap::new();
3244 routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
3245 let view = view_with_routes(routes, vec![], vec![]);
3246 let buf = build_query(0x102, &["api", "corp", "example"], 65, 1);
3247
3248 match decide(&view, &buf).expect("decides") {
3249 Decision::Forward {
3250 upstreams, query, ..
3251 } => {
3252 assert_eq!(upstreams, vec!["10.0.0.53:53".parse().unwrap()]);
3253 assert_eq!(query, buf, "the exotic-qtype query is forwarded verbatim");
3254 }
3255 Decision::Reply(_) => {
3256 panic!("an off-tailnet HTTPS-record query must forward, not reply")
3257 }
3258 }
3259 }
3260
3261 #[test]
3262 fn non_in_class_off_tailnet_forwards_to_upstream() {
3263 // A non-IN class for an off-tailnet routed name forwards too (Go does no class check on the
3264 // local path). Proves the class gate no longer short-circuits to REFUSED before routing.
3265 let mut routes = std::collections::BTreeMap::new();
3266 routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
3267 let view = view_with_routes(routes, vec![], vec![]);
3268 let buf = build_query(0x103, &["api", "corp", "example"], 1, 3);
3269
3270 match decide(&view, &buf).expect("decides") {
3271 Decision::Forward { upstreams, .. } => {
3272 assert_eq!(upstreams, vec!["10.0.0.53:53".parse().unwrap()]);
3273 }
3274 Decision::Reply(_) => {
3275 panic!("an off-tailnet non-IN-class query must forward, not reply")
3276 }
3277 }
3278 }
3279
3280 /// The local responder bounds concurrent in-flight forwards: `serve` acquires one
3281 /// `MAX_INFLIGHT_FORWARDS` permit per spawned forward task and drops the query fail-closed when
3282 /// the pool is exhausted (a client spraying forwardable names can't open unbounded overlay
3283 /// sockets). This pins the gating semantics `serve` relies on — drained pool refuses a new
3284 /// permit; releasing one restores capacity — and the cap constant itself. (The async `serve`
3285 /// loop has no netstack-free test seam, so the semaphore behavior is exercised directly here, the
3286 /// same `Arc<Semaphore>::try_acquire_owned` the loop uses.)
3287 #[test]
3288 fn forward_inflight_cap_fails_closed_when_saturated() {
3289 use std::sync::Arc;
3290
3291 use tokio::sync::Semaphore;
3292
3293 let inflight = Arc::new(Semaphore::new(MAX_INFLIGHT_FORWARDS));
3294
3295 // Drain every permit (one per concurrently in-flight forward).
3296 let mut held = Vec::with_capacity(MAX_INFLIGHT_FORWARDS);
3297 for _ in 0..MAX_INFLIGHT_FORWARDS {
3298 held.push(
3299 inflight
3300 .clone()
3301 .try_acquire_owned()
3302 .expect("permits available below the cap"),
3303 );
3304 }
3305
3306 // At the cap, the next forward is refused — `serve` would drop the query, not spawn.
3307 assert!(
3308 inflight.clone().try_acquire_owned().is_err(),
3309 "a saturated forward pool must refuse a new permit (fail closed)"
3310 );
3311
3312 // Completing an in-flight forward releases its permit and restores capacity.
3313 drop(held.pop());
3314 assert!(
3315 inflight.clone().try_acquire_owned().is_ok(),
3316 "releasing a permit must let the next forward proceed"
3317 );
3318 }
3319
3320 /// A permit moved into a spawned forward task (the `let _permit = permit;` shape `serve` uses)
3321 /// must stay held for the *whole* task body — across the `.await` on the upstream — and release
3322 /// only when the task completes. This guards the regression the saturation test above can't see:
3323 /// "tidying" `let _permit = permit;` to `let _ = permit;` would drop the permit immediately,
3324 /// re-opening unbounded concurrency while leaving the synchronous drain/restore test green. Here a
3325 /// 1-permit pool is consumed by a task that holds it across a yield; the pool must read empty
3326 /// while the task runs and refill once it finishes.
3327 #[tokio::test]
3328 async fn forward_permit_is_held_for_the_task_lifetime_not_dropped_early() {
3329 use std::sync::Arc;
3330
3331 use tokio::sync::Semaphore;
3332
3333 let inflight = Arc::new(Semaphore::new(1));
3334 let permit = inflight
3335 .clone()
3336 .try_acquire_owned()
3337 .expect("the sole permit is available");
3338
3339 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
3340 let (release_tx, release_rx) = tokio::sync::oneshot::channel();
3341 let task = tokio::spawn(async move {
3342 // Same shape as `serve`'s spawned forward: the permit is a named binding moved into the
3343 // task, so it lives until the body ends — not dropped at the `let`.
3344 let _permit = permit;
3345 started_tx.send(()).unwrap();
3346 // Stand in for the `.await` on the upstream forward.
3347 release_rx.await.unwrap();
3348 });
3349
3350 started_rx.await.unwrap();
3351 // While the task runs, the permit it moved in is still held — the pool is empty.
3352 assert!(
3353 inflight.clone().try_acquire_owned().is_err(),
3354 "a permit moved into a running task must stay held across its await"
3355 );
3356
3357 // Let the task finish; its permit drops with the body and capacity returns.
3358 release_tx.send(()).unwrap();
3359 task.await.unwrap();
3360 assert!(
3361 inflight.clone().try_acquire_owned().is_ok(),
3362 "the permit must be released once the task body completes"
3363 );
3364 }
3365
3366 /// The address of the `n`th fake upstream resolver (RFC 5737 documentation range).
3367 fn upstream_addr(n: u8) -> SocketAddr {
3368 SocketAddr::from((Ipv4Addr::new(198, 51, 100, n), 53))
3369 }
3370
3371 /// Turn `query` into an upstream response: echo the header and question back with `QR` set and
3372 /// `rcode` in the header's low nibble, then append `tail` verbatim, counted as `ancount` answer
3373 /// records. The forwarder relays bytes and never parses past the question, so an opaque tail is
3374 /// what tells two upstreams' responses apart — and stands in for the RFC 8914 extended DNS error
3375 /// a real resolver puts in its own SERVFAIL/REFUSED.
3376 fn upstream_response(query: &[u8], rcode: u8, ancount: u16, tail: &[u8]) -> Vec<u8> {
3377 let mut resp = query.to_vec();
3378 resp[2] |= 0x80; // QR = 1 (this is a response)
3379 resp[3] = (resp[3] & 0xF0) | rcode;
3380 resp[6..8].copy_from_slice(&ancount.to_be_bytes());
3381 resp.extend_from_slice(tail);
3382 resp
3383 }
3384
3385 /// One scripted upstream for [`run_forward_walk`]: the upstream's address, and the
3386 /// `(source address, datagram)` it hands back — `None` when nothing came back at all.
3387 type ScriptedUpstream = (SocketAddr, Option<(SocketAddr, Vec<u8>)>);
3388
3389 /// Run the real [`forward_walk`] over a scripted set of upstreams: each entry is
3390 /// `(upstream, answer)`, where `answer` is the `(source address, datagram)` that upstream hands
3391 /// back (`None` = nothing came back — a timeout, a bind/send/recv failure). Returns the bytes
3392 /// the client would get **and** the upstreams the walk actually asked, so a test can tell "the
3393 /// second upstream answered" apart from "the walk stopped at the first".
3394 ///
3395 /// The script stands in for [`ask_upstream`]'s overlay socket exchange only; every decision
3396 /// under test — the source/transaction-id check, the REFUSED/SERVFAIL soft-error rules, which
3397 /// response is relayed — is made by the production code being called. Every scripted answer
3398 /// arrives over the UDP hop, which is the hop the walk's own rules are about, and is read by the
3399 /// production race with the TCP hop switched off ([`TcpRetry::Disabled`]), so a scripted soft
3400 /// error reaches the walk as the failure [`ask_upstream`] would hand it. The TCP hop is
3401 /// [`race_udp_and_tcp`]'s business and is tested there.
3402 async fn run_forward_walk(
3403 script: &[ScriptedUpstream],
3404 query: &[u8],
3405 fallback: Vec<u8>,
3406 ) -> (Vec<u8>, Vec<SocketAddr>) {
3407 let upstreams: Vec<SocketAddr> = script.iter().map(|(upstream, _)| *upstream).collect();
3408 let asked = std::cell::RefCell::new(Vec::new());
3409
3410 let response = forward_walk(
3411 &upstreams,
3412 query,
3413 fallback,
3414 ClientTransport::Udp,
3415 |upstream| {
3416 asked.borrow_mut().push(upstream);
3417 let datagram = script
3418 .iter()
3419 .find(|(scripted, _)| *scripted == upstream)
3420 .and_then(|(_, datagram)| datagram.clone());
3421 race_udp_and_tcp(
3422 upstream,
3423 query,
3424 ClientTransport::Udp,
3425 TcpRetry::Disabled,
3426 move || std::future::ready(datagram),
3427 || std::future::ready(None),
3428 )
3429 },
3430 )
3431 .await;
3432
3433 (response, asked.into_inner())
3434 }
3435
3436 /// A first upstream answering REFUSED must NOT end the forward. A broken or misconfigured
3437 /// resolver refuses instantly and would otherwise beat a healthy one that is still working,
3438 /// handing the stub resolver a refusal as though it were the answer — complete DNS failure
3439 /// wherever a split-DNS route or a fallback list names more than one resolver.
3440 #[tokio::test]
3441 async fn refused_first_upstream_does_not_end_the_walk() {
3442 let query = build_query(0x201, &["api", "example", "com"], 1, 1);
3443 let (first, second) = (upstream_addr(1), upstream_addr(2));
3444 let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"refused");
3445 let answer = upstream_response(&query, 0, 1, b"the real answer");
3446 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3447
3448 let (got, asked) = run_forward_walk(
3449 &[
3450 (first, Some((first, refusal))),
3451 (second, Some((second, answer.clone()))),
3452 ],
3453 &query,
3454 fallback,
3455 )
3456 .await;
3457
3458 assert_eq!(
3459 asked,
3460 vec![first, second],
3461 "a REFUSED from the first upstream must not stop the walk"
3462 );
3463 assert_eq!(
3464 got, answer,
3465 "the healthy second upstream's answer is what reaches the client"
3466 );
3467 }
3468
3469 /// SERVFAIL is soft in the same way: the walk goes on and the healthy upstream's answer wins.
3470 #[tokio::test]
3471 async fn servfail_first_upstream_does_not_end_the_walk() {
3472 let query = build_query(0x202, &["api", "example", "com"], 1, 1);
3473 let (first, second) = (upstream_addr(1), upstream_addr(2));
3474 let soft_fail = upstream_response(&query, RCODE_SERVFAIL, 0, b"servfail");
3475 let answer = upstream_response(&query, 0, 1, b"the real answer");
3476 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3477
3478 let (got, asked) = run_forward_walk(
3479 &[
3480 (first, Some((first, soft_fail))),
3481 (second, Some((second, answer.clone()))),
3482 ],
3483 &query,
3484 fallback,
3485 )
3486 .await;
3487
3488 assert_eq!(asked, vec![first, second], "SERVFAIL is a soft error too");
3489 assert_eq!(
3490 got, answer,
3491 "the second upstream's answer reaches the client"
3492 );
3493 }
3494
3495 /// An RCODE that is *not* soft is an answer: NXDOMAIN ends the walk where it is found, and the
3496 /// upstreams after it are never asked. (Making everything soft would turn a legitimate
3497 /// "no such name" into a needless extra round trip — and, with a second refusing upstream, into
3498 /// a different answer entirely.)
3499 #[tokio::test]
3500 async fn nxdomain_ends_the_walk_at_the_first_upstream() {
3501 let query = build_query(0x203, &["nope", "example", "com"], 1, 1);
3502 let (first, second) = (upstream_addr(1), upstream_addr(2));
3503 let nxdomain = upstream_response(&query, 3, 0, b"no such name");
3504 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3505
3506 let (got, asked) = run_forward_walk(
3507 &[
3508 (first, Some((first, nxdomain.clone()))),
3509 (
3510 second,
3511 Some((second, upstream_response(&query, 0, 1, b"late"))),
3512 ),
3513 ],
3514 &query,
3515 fallback,
3516 )
3517 .await;
3518
3519 assert_eq!(asked, vec![first], "NXDOMAIN is an answer: stop asking");
3520 assert_eq!(got, nxdomain, "and it is what the client gets");
3521 }
3522
3523 /// When every upstream refuses, the client gets the FIRST refusal, byte for byte — not the
3524 /// caller's synthesized SERVFAIL. The upstream's own bytes can carry an RFC 8914 extended DNS
3525 /// error explaining the refusal; a locally built packet throws that away.
3526 #[tokio::test]
3527 async fn every_upstream_refusing_returns_the_first_refusal_verbatim() {
3528 let query = build_query(0x204, &["api", "example", "com"], 1, 1);
3529 let (first, second) = (upstream_addr(1), upstream_addr(2));
3530 let first_refusal =
3531 upstream_response(&query, RCODE_REFUSED, 0, b"first refusal + extended error");
3532 let second_refusal = upstream_response(&query, RCODE_REFUSED, 0, b"second refusal");
3533 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3534
3535 let (got, asked) = run_forward_walk(
3536 &[
3537 (first, Some((first, first_refusal.clone()))),
3538 (second, Some((second, second_refusal.clone()))),
3539 ],
3540 &query,
3541 fallback.clone(),
3542 )
3543 .await;
3544
3545 assert_eq!(
3546 asked,
3547 vec![first, second],
3548 "every upstream is given its turn"
3549 );
3550 assert_eq!(
3551 got, first_refusal,
3552 "an all-refused forward relays the first upstream's own REFUSED bytes"
3553 );
3554 assert_ne!(
3555 got, fallback,
3556 "the synthesized SERVFAIL must not replace an upstream's own response"
3557 );
3558 assert_ne!(got, second_refusal, "the FIRST refusal is the one kept");
3559 }
3560
3561 /// The first *soft* response is the one kept whichever code it carried: a SERVFAIL followed by a
3562 /// REFUSED relays the upstream's own SERVFAIL, extended error and all, rather than the
3563 /// synthesized one the caller supplied.
3564 #[tokio::test]
3565 async fn every_upstream_soft_failing_returns_the_upstream_servfail_not_the_fallback() {
3566 let query = build_query(0x205, &["api", "example", "com"], 1, 1);
3567 let (first, second) = (upstream_addr(1), upstream_addr(2));
3568 let upstream_servfail = upstream_response(
3569 &query,
3570 RCODE_SERVFAIL,
3571 0,
3572 b"upstream servfail + extended error",
3573 );
3574 let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"second refusal");
3575 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3576
3577 let (got, _asked) = run_forward_walk(
3578 &[
3579 (first, Some((first, upstream_servfail.clone()))),
3580 (second, Some((second, refusal))),
3581 ],
3582 &query,
3583 fallback.clone(),
3584 )
3585 .await;
3586
3587 assert_eq!(
3588 got, upstream_servfail,
3589 "the upstream's own SERVFAIL is relayed verbatim, keeping any extended DNS error"
3590 );
3591 assert_ne!(got, fallback, "not the locally synthesized SERVFAIL");
3592 }
3593
3594 /// A first REFUSED is relayed only when every failure was a refusal. When a later upstream
3595 /// answered SERVFAIL, Go's tally has seen a non-refusal and the first failure is not a SERVFAIL,
3596 /// so the client gets the synthesized SERVFAIL — neither the refusal nor the later SERVFAIL.
3597 #[tokio::test]
3598 async fn refusal_then_servfail_returns_the_fallback_not_the_refusal() {
3599 let query = build_query(0x20a, &["api", "example", "com"], 1, 1);
3600 let (first, second) = (upstream_addr(1), upstream_addr(2));
3601 let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"first refusal");
3602 let servfail = upstream_response(&query, RCODE_SERVFAIL, 0, b"second servfail");
3603 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3604
3605 let (got, asked) = run_forward_walk(
3606 &[
3607 (first, Some((first, refusal.clone()))),
3608 (second, Some((second, servfail.clone()))),
3609 ],
3610 &query,
3611 fallback.clone(),
3612 )
3613 .await;
3614
3615 assert_eq!(asked, vec![first, second]);
3616 assert_eq!(
3617 got, fallback,
3618 "a SERVFAIL anywhere means the first REFUSED is not what the client is told"
3619 );
3620 assert_ne!(got, refusal);
3621 assert_ne!(got, servfail, "only a FIRST failure's SERVFAIL is relayed");
3622 }
3623
3624 /// The same when the later upstream said nothing at all: a timeout is a non-refusal failure, so
3625 /// the first REFUSED gives way to the synthesized SERVFAIL.
3626 #[tokio::test]
3627 async fn refusal_then_silent_upstream_returns_the_fallback_not_the_refusal() {
3628 let query = build_query(0x20b, &["api", "example", "com"], 1, 1);
3629 let (first, second) = (upstream_addr(1), upstream_addr(2));
3630 let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"first refusal");
3631 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3632
3633 let (got, asked) = run_forward_walk(
3634 &[(first, Some((first, refusal.clone()))), (second, None)],
3635 &query,
3636 fallback.clone(),
3637 )
3638 .await;
3639
3640 assert_eq!(asked, vec![first, second]);
3641 assert_eq!(got, fallback, "a silent upstream is not a refusal");
3642 }
3643
3644 /// When the first failure is a silence, a later upstream's SERVFAIL is not relayed either: Go
3645 /// relays an upstream's bytes only when the FIRST error carries them.
3646 #[tokio::test]
3647 async fn silent_upstream_then_servfail_returns_the_fallback() {
3648 let query = build_query(0x20c, &["api", "example", "com"], 1, 1);
3649 let (first, second) = (upstream_addr(1), upstream_addr(2));
3650 let servfail = upstream_response(&query, RCODE_SERVFAIL, 0, b"second servfail");
3651 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3652
3653 let (got, _asked) = run_forward_walk(
3654 &[(first, None), (second, Some((second, servfail.clone())))],
3655 &query,
3656 fallback.clone(),
3657 )
3658 .await;
3659
3660 assert_eq!(got, fallback);
3661 assert_ne!(got, servfail);
3662 }
3663
3664 /// A lone upstream that refuses still has its refusal relayed: with nothing else to wait for,
3665 /// treating REFUSED as soft changes nothing about what the client is told.
3666 #[tokio::test]
3667 async fn lone_refusing_upstream_still_has_its_refusal_relayed() {
3668 let query = build_query(0x206, &["api", "example", "com"], 1, 1);
3669 let only = upstream_addr(1);
3670 let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"refused");
3671 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3672
3673 let (got, asked) =
3674 run_forward_walk(&[(only, Some((only, refusal.clone())))], &query, fallback).await;
3675
3676 assert_eq!(asked, vec![only]);
3677 assert_eq!(
3678 got, refusal,
3679 "a single upstream's REFUSED is the client's answer"
3680 );
3681 }
3682
3683 /// The anti-poisoning check still runs BEFORE any of the soft-error handling: a datagram whose
3684 /// transaction id is not the one we asked with is discarded outright and never remembered as
3685 /// "the first REFUSED", so an off-path injector cannot plant the response an all-refused forward
3686 /// ends up relaying.
3687 #[tokio::test]
3688 async fn wrong_transaction_id_response_is_discarded_not_remembered_as_a_soft_error() {
3689 let query = build_query(0x207, &["api", "example", "com"], 1, 1);
3690 let only = upstream_addr(1);
3691 let mut poisoned = upstream_response(&query, RCODE_REFUSED, 0, b"injected");
3692 poisoned[0] ^= 0xFF; // a transaction id we never asked with
3693 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3694
3695 let (got, _asked) = run_forward_walk(
3696 &[(only, Some((only, poisoned.clone())))],
3697 &query,
3698 fallback.clone(),
3699 )
3700 .await;
3701
3702 assert_ne!(
3703 got, poisoned,
3704 "a mismatched transaction id must never be relayed"
3705 );
3706 assert_eq!(
3707 got, fallback,
3708 "with the datagram discarded nothing answered, so the synthesized fallback stands"
3709 );
3710 }
3711
3712 /// The same for the source check: a well-formed REFUSED that echoes the question and the
3713 /// transaction id but arrives from an address we did not query is discarded before it can become
3714 /// the forward's remembered soft error.
3715 #[tokio::test]
3716 async fn off_path_source_response_is_discarded_not_remembered_as_a_soft_error() {
3717 let query = build_query(0x208, &["api", "example", "com"], 1, 1);
3718 let (only, off_path) = (upstream_addr(1), upstream_addr(9));
3719 let poisoned = upstream_response(&query, RCODE_REFUSED, 0, b"injected");
3720 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3721
3722 let (got, _asked) = run_forward_walk(
3723 &[(only, Some((off_path, poisoned.clone())))],
3724 &query,
3725 fallback.clone(),
3726 )
3727 .await;
3728
3729 assert_ne!(
3730 got, poisoned,
3731 "a datagram from an unqueried source must never be relayed"
3732 );
3733 assert_eq!(
3734 got, fallback,
3735 "with the datagram discarded nothing answered, so the synthesized fallback stands"
3736 );
3737 }
3738
3739 /// An upstream that says nothing at all (timeout, bind/send/recv failure) is simply skipped, and
3740 /// the next upstream's answer is what the client gets.
3741 #[tokio::test]
3742 async fn silent_upstream_is_skipped_for_the_next_one() {
3743 let query = build_query(0x209, &["api", "example", "com"], 1, 1);
3744 let (first, second) = (upstream_addr(1), upstream_addr(2));
3745 let answer = upstream_response(&query, 0, 1, b"the real answer");
3746 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3747
3748 let (got, asked) = run_forward_walk(
3749 &[(first, None), (second, Some((second, answer.clone())))],
3750 &query,
3751 fallback,
3752 )
3753 .await;
3754
3755 assert_eq!(asked, vec![first, second]);
3756 assert_eq!(got, answer);
3757 }
3758
3759 /// A REFUSED is relayed only when EVERY upstream refused. Once a later upstream SERVFAILs, the
3760 /// forward did not fail by policy alone, and Go's forwarder answers SERVFAIL: its first error is
3761 /// not a SERVFAIL response, so it synthesizes one rather than relaying the refusal.
3762 #[tokio::test]
3763 async fn refusal_then_servfail_is_the_fallback_not_the_refusal() {
3764 let query = build_query(0x20A, &["api", "example", "com"], 1, 1);
3765 let (first, second) = (upstream_addr(1), upstream_addr(2));
3766 let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"refused");
3767 let servfail = upstream_response(&query, RCODE_SERVFAIL, 0, b"second servfail");
3768 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3769
3770 let (got, asked) = run_forward_walk(
3771 &[
3772 (first, Some((first, refusal.clone()))),
3773 (second, Some((second, servfail.clone()))),
3774 ],
3775 &query,
3776 fallback.clone(),
3777 )
3778 .await;
3779
3780 assert_eq!(asked, vec![first, second]);
3781 assert_eq!(
3782 got, fallback,
3783 "a refusal mixed with a SERVFAIL is a SERVFAIL"
3784 );
3785 assert_ne!(got, refusal, "the first refusal must not be relayed");
3786 assert_ne!(
3787 got, servfail,
3788 "nor a later SERVFAIL that was not the first failure"
3789 );
3790 }
3791
3792 /// The same when the later upstream never answers: a transport failure is not a refusal, so the
3793 /// forward is no longer "every upstream refused" and the client gets the SERVFAIL fallback.
3794 #[tokio::test]
3795 async fn refusal_then_silence_is_the_fallback_not_the_refusal() {
3796 let query = build_query(0x20B, &["api", "example", "com"], 1, 1);
3797 let (first, second) = (upstream_addr(1), upstream_addr(2));
3798 let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"refused");
3799 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3800
3801 let (got, asked) = run_forward_walk(
3802 &[(first, Some((first, refusal.clone()))), (second, None)],
3803 &query,
3804 fallback.clone(),
3805 )
3806 .await;
3807
3808 assert_eq!(asked, vec![first, second]);
3809 assert_eq!(got, fallback, "a refusal then no answer is a SERVFAIL");
3810 }
3811
3812 /// A datagram the anti-poisoning check discards counts as a non-refusal failure too (Go's
3813 /// transaction-id mismatch is an error that is not `errRefused`), so it also stops an earlier
3814 /// refusal from being relayed.
3815 #[tokio::test]
3816 async fn refusal_then_mismatched_response_is_the_fallback_not_the_refusal() {
3817 let query = build_query(0x20C, &["api", "example", "com"], 1, 1);
3818 let (first, second) = (upstream_addr(1), upstream_addr(2));
3819 let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"refused");
3820 let mut mismatched = upstream_response(&query, RCODE_REFUSED, 0, b"wrong txid");
3821 mismatched[0] ^= 0xFF;
3822 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3823
3824 let (got, _asked) = run_forward_walk(
3825 &[
3826 (first, Some((first, refusal))),
3827 (second, Some((second, mismatched))),
3828 ],
3829 &query,
3830 fallback.clone(),
3831 )
3832 .await;
3833
3834 assert_eq!(got, fallback);
3835 }
3836
3837 /// An upstream SERVFAIL is relayed only when it was the FIRST failure. After an upstream that
3838 /// never answered, Go's first error is a transport error, not a SERVFAIL response, so it
3839 /// synthesizes the SERVFAIL rather than relaying the later upstream's.
3840 #[tokio::test]
3841 async fn silence_then_servfail_is_the_fallback_not_the_upstream_servfail() {
3842 let query = build_query(0x20D, &["api", "example", "com"], 1, 1);
3843 let (first, second) = (upstream_addr(1), upstream_addr(2));
3844 let servfail = upstream_response(&query, RCODE_SERVFAIL, 0, b"second servfail");
3845 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3846
3847 let (got, asked) = run_forward_walk(
3848 &[(first, None), (second, Some((second, servfail.clone())))],
3849 &query,
3850 fallback.clone(),
3851 )
3852 .await;
3853
3854 assert_eq!(asked, vec![first, second]);
3855 assert_eq!(got, fallback);
3856 assert_ne!(got, servfail);
3857 }
3858
3859 /// The mirror of the case above, and the only branch that still relays an upstream's bytes once
3860 /// a non-refusal has been seen: the FIRST failure is an upstream SERVFAIL and the second
3861 /// upstream is silent. The held SERVFAIL is relayed verbatim, so its RFC 8914 extended DNS error
3862 /// survives rather than being thrown away for the synthesized one.
3863 ///
3864 /// No other test reaches that branch with `saw_non_refused` set by an upstream that sent
3865 /// nothing at all, so this one pins that a later failure carrying no bytes neither replaces the
3866 /// held SERVFAIL nor demotes the forward to `fallback`.
3867 #[tokio::test]
3868 async fn servfail_then_silent_upstream_relays_the_upstream_servfail() {
3869 let query = build_query(0x20e, &["api", "example", "com"], 1, 1);
3870 let (first, second) = (upstream_addr(1), upstream_addr(2));
3871 let servfail = upstream_response(&query, RCODE_SERVFAIL, 0, b"first servfail");
3872 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3873
3874 let (got, asked) = run_forward_walk(
3875 &[(first, Some((first, servfail.clone()))), (second, None)],
3876 &query,
3877 fallback,
3878 )
3879 .await;
3880
3881 assert_eq!(
3882 asked,
3883 vec![first, second],
3884 "the walk must ask both upstreams"
3885 );
3886 assert_eq!(
3887 got, servfail,
3888 "the first failure's own SERVFAIL bytes are relayed, not the synthesized fallback"
3889 );
3890 }
3891
3892 /// An upstream's own soft error is relayed as it was read, as Go relays `rcodeResponseError.res`:
3893 /// no `TC` for the client's advertised size. This query advertises no EDNS size, so a client
3894 /// limit of 512 applies to an *answer*; a REFUSED padded past it — as a long RFC 8914 extended
3895 /// error text would pad it — still reaches the client whole and with `TC` clear.
3896 #[tokio::test]
3897 async fn a_relayed_soft_error_is_not_marked_truncated_for_the_client_size() {
3898 let query = build_query(0x41A, &["example", "com"], 1, 1);
3899 let only = upstream_addr(1);
3900 let refusal = upstream_response(&query, RCODE_REFUSED, 0, &[0xEE; 700]);
3901 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3902
3903 let (got, _asked) = run_forward_walk(
3904 &[(only, Some((only, refusal.clone())))],
3905 &query,
3906 fallback.clone(),
3907 )
3908 .await;
3909
3910 assert_eq!(got, refusal, "relayed byte for byte");
3911 assert_eq!(got[2] & 0x02, 0, "TC clear, as Go sends it");
3912
3913 // The one bound kept is the datagram's, and it is Go's own for the UDP hop: `sendUDP` cuts
3914 // what it read to `maxResponseBytes` before the RCODE switch returns — without setting TC.
3915 let mut full_ring = upstream_response(&query, RCODE_SERVFAIL, 0, b"");
3916 full_ring.resize(MAX_UPSTREAM_RESPONSE + 1, 0xEE);
3917 let (got, _asked) =
3918 run_forward_walk(&[(only, Some((only, full_ring.clone())))], &query, fallback).await;
3919
3920 assert_eq!(
3921 got,
3922 full_ring[..MAX_UPSTREAM_RESPONSE],
3923 "cut to one datagram"
3924 );
3925 assert_eq!(got[2] & 0x02, 0, "and still not marked truncated");
3926 }
3927
3928 /// Off a datagram entirely — fetched over TCP for a TCP client — a relayed soft error is not cut
3929 /// at all.
3930 #[test]
3931 fn a_soft_error_fetched_over_tcp_for_a_tcp_client_is_relayed_whole() {
3932 let query = build_query(0x41B, &["example", "com"], 1, 1);
3933 let mut big = upstream_response(&query, RCODE_SERVFAIL, 0, b"");
3934 big.resize(MAX_UPSTREAM_RESPONSE + 5000, 0xEE);
3935
3936 let out = relay_soft_error(
3937 UpstreamAnswer {
3938 from: upstream_addr(1),
3939 resp: big.clone(),
3940 via: UpstreamTransport::Tcp,
3941 },
3942 ClientTransport::Tcp,
3943 );
3944 assert_eq!(out, big);
3945 }
3946
3947 /// A discarded datagram counts as a non-refusal failure whatever it claims to carry: a spoofed
3948 /// NOERROR answer with a transaction id we never asked with is neither relayed nor mistaken for
3949 /// the walk's end, and still stops an earlier refusal from being relayed.
3950 #[tokio::test]
3951 async fn refusal_then_discarded_answer_is_the_fallback_not_the_refusal() {
3952 let query = build_query(0x20E, &["api", "example", "com"], 1, 1);
3953 let (first, second) = (upstream_addr(1), upstream_addr(2));
3954 let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"first refusal");
3955 let mut poisoned = upstream_response(&query, 0, 1, b"injected");
3956 poisoned[0] ^= 0xFF; // a transaction id we never asked with
3957 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3958
3959 let (got, _asked) = run_forward_walk(
3960 &[
3961 (first, Some((first, refusal))),
3962 (second, Some((second, poisoned))),
3963 ],
3964 &query,
3965 fallback.clone(),
3966 )
3967 .await;
3968
3969 assert_eq!(got, fallback);
3970 }
3971
3972 #[test]
3973 fn longest_suffix_route_wins() {
3974 let mut routes = std::collections::BTreeMap::new();
3975 routes.insert("example".to_string(), vec![udp("10.0.0.1:53")]);
3976 routes.insert("corp.example".to_string(), vec![udp("10.0.0.2:53")]);
3977 let view = view_with_routes(routes, vec![], vec![]);
3978 let buf = build_query(0x101, &["api", "corp", "example"], 1, 1);
3979
3980 match decide(&view, &buf).expect("decides") {
3981 Decision::Forward { upstreams, .. } => {
3982 assert_eq!(
3983 upstreams,
3984 vec!["10.0.0.2:53".parse().unwrap()],
3985 "longer suffix wins"
3986 );
3987 }
3988 Decision::Reply(_) => panic!("expected forward"),
3989 }
3990 }
3991
3992 #[test]
3993 fn negative_route_is_nxdomain_not_forwarded() {
3994 // An empty upstream list is a negative route: fail closed, never forward.
3995 let mut routes = std::collections::BTreeMap::new();
3996 routes.insert("blocked.example".to_string(), vec![]);
3997 let view = view_with_routes(routes, vec![udp("8.8.8.8:53")], vec![]);
3998 let buf = build_query(0x102, &["x", "blocked", "example"], 1, 1);
3999
4000 match decide(&view, &buf).expect("decides") {
4001 Decision::Reply(resp) => {
4002 let (_, rcode, _) = parse_header(&resp);
4003 assert_eq!(rcode, 3, "NxDomain: negative route is not forwarded");
4004 }
4005 Decision::Forward { .. } => panic!("negative route must not forward"),
4006 }
4007 }
4008
4009 #[test]
4010 fn unrouted_name_forwards_to_fallback_then_global() {
4011 // No route matches: fallback resolvers are preferred over global resolvers.
4012 let view = view_with_routes(
4013 std::collections::BTreeMap::new(),
4014 vec![udp("8.8.8.8:53")],
4015 vec![udp("1.1.1.1:53")],
4016 );
4017 let buf = build_query(0x103, &["example", "com"], 1, 1);
4018
4019 match decide(&view, &buf).expect("decides") {
4020 Decision::Forward { upstreams, .. } => {
4021 assert_eq!(
4022 upstreams,
4023 vec!["1.1.1.1:53".parse().unwrap()],
4024 "fallback preferred"
4025 );
4026 }
4027 Decision::Reply(_) => panic!("expected forward to fallback"),
4028 }
4029 }
4030
4031 #[test]
4032 fn unrouted_name_forwards_to_global_when_no_fallback() {
4033 let view = view_with_routes(
4034 std::collections::BTreeMap::new(),
4035 vec![udp("8.8.8.8:53")],
4036 vec![],
4037 );
4038 let buf = build_query(0x104, &["example", "com"], 1, 1);
4039
4040 match decide(&view, &buf).expect("decides") {
4041 Decision::Forward { upstreams, .. } => {
4042 assert_eq!(upstreams, vec!["8.8.8.8:53".parse().unwrap()]);
4043 }
4044 Decision::Reply(_) => panic!("expected forward to global resolver"),
4045 }
4046 }
4047
4048 #[test]
4049 fn tailnet_name_is_never_forwarded() {
4050 // Anti-leak: a name under a tailnet search domain that has no overlay match must fail
4051 // closed to NXDOMAIN, never leak to an upstream resolver, even with resolvers configured.
4052 let view = view_with_routes(
4053 std::collections::BTreeMap::new(),
4054 vec![udp("8.8.8.8:53")],
4055 vec![udp("1.1.1.1:53")],
4056 );
4057 // "ghost.user.ts.net" is under the tailnet suffix but matches no peer.
4058 let buf = build_query(0x105, &["ghost", "user", "ts", "net"], 1, 1);
4059
4060 match decide(&view, &buf).expect("decides") {
4061 Decision::Reply(resp) => {
4062 let (_, rcode, _) = parse_header(&resp);
4063 assert_eq!(rcode, 3, "NxDomain: tailnet name not leaked upstream");
4064 }
4065 Decision::Forward { .. } => panic!("tailnet name must never be forwarded"),
4066 }
4067 }
4068
4069 #[test]
4070 fn no_resolvers_off_tailnet_is_servfail_not_nxdomain() {
4071 // No route, no resolvers: an OFF-tailnet name cannot be forwarded. Go answers SERVFAIL
4072 // (forwarder.go:1207 "no upstream resolvers set, returning SERVFAIL"), NOT NXDOMAIN — a
4073 // cacheable non-existence for a real name we merely couldn't forward would poison downstream
4074 // stub caches. We still never forward (the name does not leak); we just soft-fail.
4075 let view = view_with_routes(std::collections::BTreeMap::new(), vec![], vec![]);
4076 let buf = build_query(0x106, &["example", "com"], 1, 1);
4077
4078 match decide(&view, &buf).expect("decides") {
4079 Decision::Reply(resp) => {
4080 let (_, rcode, _) = parse_header(&resp);
4081 assert_eq!(
4082 rcode, 2,
4083 "ServFail: off-tailnet name with no upstream to forward to"
4084 );
4085 }
4086 Decision::Forward { .. } => panic!("must not forward with no resolvers"),
4087 }
4088 }
4089
4090 #[test]
4091 fn route_with_only_ipv6_upstreams_off_tailnet_is_servfail() {
4092 // A split-DNS route exists but every resolver is IPv6 (filtered out under the IPv4-only
4093 // egress): we have a route yet nowhere to forward. That is an inability to forward an
4094 // off-tailnet name, so SERVFAIL (soft), not a fabricated NXDOMAIN.
4095 let mut routes = std::collections::BTreeMap::new();
4096 routes.insert("corp.example".to_string(), vec![udp("[2001:db8::53]:53")]);
4097 let view = view_with_routes(routes, vec![], vec![]);
4098 let buf = build_query(0x108, &["host", "corp", "example"], 1, 1);
4099
4100 match decide(&view, &buf).expect("decides") {
4101 Decision::Reply(resp) => {
4102 let (_, rcode, _) = parse_header(&resp);
4103 assert_eq!(
4104 rcode, 2,
4105 "ServFail: route's resolvers all filtered out (IPv6-only), cannot forward"
4106 );
4107 }
4108 Decision::Forward { .. } => panic!("must not forward when all upstreams are filtered"),
4109 }
4110 }
4111
4112 /// The SERVFAIL this node synthesizes is Go's `servfailResponse`: the query's own header with
4113 /// QR, AA and RCODE set, and nothing else changed. RA stays as the client sent it — clear, for
4114 /// the RD=1 query every stub resolver sends — where a locally built answer would set it; the
4115 /// AD and CD bits and the opcode survive; Z is dropped. The same bytes stand in both for "no
4116 /// upstream to ask" and, as the forward's fallback, for "every upstream failed".
4117 #[test]
4118 fn a_synthesized_servfail_copies_the_query_header_as_go_does() {
4119 let mut buf = build_query(0x10A, &["example", "com"], 1, 1);
4120 buf[2] = 0x01; // RD
4121 buf[3] = 0x70; // Z | AD | CD, RA clear
4122 let check = |resp: &[u8], what: &str| {
4123 assert_eq!(&resp[..2], &buf[..2], "{what}: the query's id");
4124 assert_eq!(
4125 resp[2], 0x85,
4126 "{what}: QR | AA | RD — nothing else in the high byte"
4127 );
4128 assert_eq!(
4129 resp[3], 0x32,
4130 "{what}: AD | CD as sent, RA not set, Z dropped, RCODE = SERVFAIL"
4131 );
4132 assert_eq!(&resp[4..6], &[0, 1], "{what}: one question, echoed");
4133 assert_eq!(&resp[6..12], &[0; 6], "{what}: no other records");
4134 assert_eq!(&resp[12..], &buf[12..], "{what}: the question as asked");
4135 };
4136
4137 let view = view_with_routes(std::collections::BTreeMap::new(), vec![], vec![]);
4138 match decide(&view, &buf).expect("decides") {
4139 Decision::Reply(resp) => check(&resp, "no upstream"),
4140 Decision::Forward { .. } => panic!("must not forward with no resolvers"),
4141 }
4142
4143 let view = view_with_routes(
4144 std::collections::BTreeMap::new(),
4145 vec![udp("192.0.2.53:53")],
4146 vec![],
4147 );
4148 match decide(&view, &buf).expect("decides") {
4149 Decision::Forward { servfail, .. } => check(&servfail, "forward fallback"),
4150 Decision::Reply(_) => panic!("an off-tailnet name with a resolver is forwarded"),
4151 }
4152
4153 // A non-QUERY opcode and a TC bit on the query are copied too.
4154 buf[2] = 0x13; // opcode 2 (STATUS) | TC | RD
4155 let view = view_with_routes(std::collections::BTreeMap::new(), vec![], vec![]);
4156 match decide(&view, &buf).expect("decides") {
4157 Decision::Reply(resp) => assert_eq!(resp[2], 0x80 | 0x10 | 0x04 | 0x03),
4158 Decision::Forward { .. } => panic!("must not forward with no resolvers"),
4159 }
4160 }
4161
4162 #[test]
4163 fn overlay_match_wins_over_forwarding() {
4164 // A known peer name resolves authoritatively even when upstream resolvers are configured.
4165 let mut db = PeerDb::default();
4166 db.upsert(&test_node());
4167 let view = DnsView {
4168 cfg: DnsConfig {
4169 magic_dns: true,
4170 search_domains: vec!["user.ts.net".to_string()],
4171 resolvers: vec![udp("8.8.8.8:53")],
4172 ..Default::default()
4173 },
4174 peers: Some(Arc::new(db)),
4175 self_node: None,
4176 exit_doh: None,
4177 enable_ipv6: false,
4178 accept_dns: true,
4179 };
4180 let buf = build_query(0x107, &["host", "user", "ts", "net"], 1, 1);
4181
4182 match decide(&view, &buf).expect("decides") {
4183 Decision::Reply(resp) => {
4184 let (_, rcode, ancount) = parse_header(&resp);
4185 assert_eq!(rcode, 0, "authoritative answer wins");
4186 assert_eq!(ancount, 1);
4187 }
4188 Decision::Forward { .. } => panic!("overlay match must not forward"),
4189 }
4190 }
4191
4192 #[test]
4193 fn ipv6_reverse_ptr_is_nxdomain_not_forwarded() {
4194 // Anti-leak: an `ip6.arpa` reverse PTR for a tailnet ULA (fd7a:…) must fail closed to
4195 // NXDOMAIN, never be forwarded — even with an upstream resolver configured. This fork is
4196 // IPv4-only on the tailnet; forwarding would reveal that a v6 address was probed.
4197 let view = view_with_routes(
4198 std::collections::BTreeMap::new(),
4199 vec![udp("8.8.8.8:53")],
4200 vec![udp("1.1.1.1:53")],
4201 );
4202 // Reverse name for fd7a::1 (nibble-reversed) under ip6.arpa. The exact nibble labels don't
4203 // matter to the guard — any name ending in ip6.arpa must fail closed.
4204 let labels = vec![
4205 "1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0",
4206 "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "a", "7", "d", "f", "ip6",
4207 "arpa",
4208 ];
4209 let buf = build_query(0x200, &labels, 12, 1);
4210
4211 match decide(&view, &buf).expect("decides") {
4212 Decision::Reply(resp) => {
4213 let (_, rcode, _) = parse_header(&resp);
4214 assert_eq!(
4215 rcode, 3,
4216 "NxDomain: ip6.arpa reverse must not leak upstream"
4217 );
4218 }
4219 Decision::Forward { .. } => panic!("ip6.arpa PTR must never be forwarded"),
4220 }
4221 }
4222
4223 /// Both transports a client can reach this node over, for the race rules that hold for either.
4224 const BOTH_CLIENTS: [ClientTransport; 2] = [ClientTransport::Udp, ClientTransport::Tcp];
4225
4226 /// A UDP hop that hears nothing and gives up the way [`ask_upstream_udp`] does: after
4227 /// [`UPSTREAM_TIMEOUT`], with `None`. A resolver whose UDP path drops or stalls.
4228 async fn silent_udp_hop() -> Option<(SocketAddr, Vec<u8>)> {
4229 tokio::time::sleep(UPSTREAM_TIMEOUT).await;
4230 None
4231 }
4232
4233 /// The retry this module exists for: an upstream answer with the `TC` bit set is re-asked over
4234 /// TCP, and the TCP answer — not the truncated datagram — is what the walk relays.
4235 ///
4236 /// Two of Go's bounds are pinned here alongside it. The retry goes to the **same** resolver
4237 /// (walking on would hand the query to a resolver the first one already answered), which is why
4238 /// this asserts on `from`; and it re-sends the **same** query, which is why the TCP hop records
4239 /// the bytes it was asked for.
4240 #[tokio::test]
4241 async fn a_truncated_udp_answer_is_retried_over_tcp_to_the_same_resolver() {
4242 let query = build_query(0x400, &["big", "example", "com"], 16, 1);
4243 let upstream = upstream_addr(1);
4244 // What the UDP hop can carry: the resolver cut the record set and said so.
4245 let mut truncated = upstream_response(&query, 0, 1, b"the part that fit");
4246 truncated[2] |= 0x02; // TC
4247 // What TCP can carry: the whole set, far past what any datagram here could hold.
4248 let whole = upstream_response(&query, 0, 40, &[0xAB; 8000]);
4249
4250 for client in BOTH_CLIENTS {
4251 let retries = std::cell::Cell::new(0);
4252 let answer = race_udp_and_tcp(
4253 upstream,
4254 &query,
4255 client,
4256 TcpRetry::Enabled,
4257 || std::future::ready(Some((upstream, truncated.clone()))),
4258 || {
4259 retries.set(retries.get() + 1);
4260 std::future::ready(Some(whole.clone()))
4261 },
4262 )
4263 .await
4264 .expect("the upstream answered");
4265
4266 assert_eq!(
4267 answer.resp, whole,
4268 "{client:?}: the TCP answer replaces the truncated datagram"
4269 );
4270 assert_eq!(
4271 answer.via,
4272 UpstreamTransport::Tcp,
4273 "{client:?}: and is labelled as having come over TCP, so the relay cap knows not to \
4274 chop it"
4275 );
4276 assert_eq!(
4277 answer.from, upstream,
4278 "{client:?}: the retry goes to the SAME resolver, never on to the next one"
4279 );
4280 assert_eq!(
4281 retries.get(),
4282 1,
4283 "{client:?}: one retry, not one per upstream and not one per attempt"
4284 );
4285 }
4286 }
4287
4288 /// Go falls back to the truncated UDP response when the TCP retry fails, rather than to
4289 /// SERVFAIL. A truncated answer still has an intact header and question and a `TC` bit the stub
4290 /// resolver can act on; a synthesized failure is strictly less than we already had.
4291 #[tokio::test]
4292 async fn a_failed_tcp_retry_relays_the_truncated_udp_answer() {
4293 let query = build_query(0x401, &["big", "example", "com"], 16, 1);
4294 let upstream = upstream_addr(1);
4295 let mut truncated = upstream_response(&query, 0, 1, b"the part that fit");
4296 truncated[2] |= 0x02; // TC
4297
4298 for client in BOTH_CLIENTS {
4299 let answer = race_udp_and_tcp(
4300 upstream,
4301 &query,
4302 client,
4303 TcpRetry::Enabled,
4304 || std::future::ready(Some((upstream, truncated.clone()))),
4305 // The resolver refused the connection, reset it, or never framed an answer.
4306 || std::future::ready(None),
4307 )
4308 .await
4309 .expect("a failed retry must not lose the answer we already had");
4310
4311 assert_eq!(
4312 answer.resp, truncated,
4313 "{client:?}: the truncated UDP answer is relayed, not a synthesized failure"
4314 );
4315 assert_eq!(
4316 answer.via,
4317 UpstreamTransport::Udp,
4318 "{client:?}: and it is still a datagram answer, so the relay cap still applies to it"
4319 );
4320 }
4321 }
4322
4323 /// A reply that fit is never retried: the retry exists for the answer that did not, and for
4324 /// nothing else. For a UDP client, whose UDP hop has the resolver to itself for
4325 /// [`UDP_RACE_TIMEOUT`], a prompt answer that fit means no TCP connection at all — one per
4326 /// forwarded query would double the work this node makes every upstream resolver do.
4327 #[tokio::test]
4328 async fn an_answer_that_is_not_truncated_is_never_retried() {
4329 let query = build_query(0x402, &["example", "com"], 1, 1);
4330 let upstream = upstream_addr(1);
4331 let fits = upstream_response(&query, 0, 1, b"a small answer");
4332
4333 let retried = std::cell::Cell::new(false);
4334 let answer = race_udp_and_tcp(
4335 upstream,
4336 &query,
4337 ClientTransport::Udp,
4338 TcpRetry::Enabled,
4339 || std::future::ready(Some((upstream, fits.clone()))),
4340 || {
4341 retried.set(true);
4342 std::future::ready(None)
4343 },
4344 )
4345 .await
4346 .expect("the upstream answered");
4347
4348 assert!(
4349 !retried.get(),
4350 "an untruncated answer must not open a TCP connection"
4351 );
4352 assert_eq!(answer.resp, fits);
4353 assert_eq!(answer.via, UpstreamTransport::Udp);
4354 }
4355
4356 /// A TCP client gets no UDP head start — Go's race timeout is 0 for it — so its TCP hop is
4357 /// already running when the UDP answer lands. An answer that fit still wins: the TCP hop is
4358 /// dropped, not waited for, and its answer never replaces the one UDP brought.
4359 #[tokio::test(start_paused = true)]
4360 async fn a_tcp_client_races_both_hops_and_an_answer_that_fit_still_wins() {
4361 let query = build_query(0x409, &["example", "com"], 1, 1);
4362 let upstream = upstream_addr(1);
4363 let fits = upstream_response(&query, 0, 1, b"a small answer");
4364 let started = tokio::time::Instant::now();
4365
4366 let tcp_started_at = std::cell::Cell::new(None);
4367 let answer = race_udp_and_tcp(
4368 upstream,
4369 &query,
4370 ClientTransport::Tcp,
4371 TcpRetry::Enabled,
4372 async || {
4373 tokio::time::sleep(Duration::from_millis(100)).await;
4374 Some((upstream, fits.clone()))
4375 },
4376 || {
4377 tcp_started_at.set(Some(started.elapsed()));
4378 // A TCP hop still connecting when UDP answers.
4379 std::future::pending()
4380 },
4381 )
4382 .await
4383 .expect("the upstream answered");
4384
4385 assert_eq!(
4386 tcp_started_at.get(),
4387 Some(Duration::ZERO),
4388 "a TCP client's TCP hop starts alongside the UDP hop, not after a head start"
4389 );
4390 assert_eq!(answer.resp, fits, "the answer that fit wins the race");
4391 assert_eq!(answer.via, UpstreamTransport::Udp);
4392 assert_eq!(
4393 started.elapsed(),
4394 Duration::from_millis(100),
4395 "and the race ends when it lands, without waiting on the TCP hop"
4396 );
4397 }
4398
4399 /// Behaviour (1) of Go's race: a resolver that never answers over UDP but answers over TCP
4400 /// still resolves — once the UDP hop's head start runs out, not once [`UPSTREAM_TIMEOUT`] does.
4401 /// Before the race, the UDP hop was waited out in full and returned nothing, so the walk moved on
4402 /// to the next resolver or ended in SERVFAIL without TCP ever being tried.
4403 #[tokio::test(start_paused = true)]
4404 async fn a_silent_udp_hop_falls_back_to_tcp_after_the_race_timeout() {
4405 let query = build_query(0x40A, &["example", "com"], 1, 1);
4406 let upstream = upstream_addr(1);
4407 let over_tcp = upstream_response(&query, 0, 1, b"answered over tcp");
4408
4409 for (client, head_start) in [
4410 (ClientTransport::Udp, UDP_RACE_TIMEOUT),
4411 (ClientTransport::Tcp, Duration::ZERO),
4412 ] {
4413 let started = tokio::time::Instant::now();
4414 let tcp_started_at = std::cell::Cell::new(None);
4415 let answer = race_udp_and_tcp(
4416 upstream,
4417 &query,
4418 client,
4419 TcpRetry::Enabled,
4420 silent_udp_hop,
4421 || {
4422 tcp_started_at.set(Some(started.elapsed()));
4423 std::future::ready(Some(over_tcp.clone()))
4424 },
4425 )
4426 .await
4427 .expect("a resolver that answers over TCP resolves");
4428
4429 assert_eq!(answer.resp, over_tcp, "{client:?}");
4430 assert_eq!(answer.via, UpstreamTransport::Tcp, "{client:?}");
4431 assert_eq!(answer.from, upstream, "{client:?}: the same resolver");
4432 assert_eq!(
4433 tcp_started_at.get(),
4434 Some(head_start),
4435 "{client:?}: TCP joins the race when the UDP hop's head start runs out"
4436 );
4437 assert!(
4438 started.elapsed() < UPSTREAM_TIMEOUT,
4439 "{client:?}: the answer must not wait out the UDP hop's own timeout ({:?})",
4440 started.elapsed()
4441 );
4442 }
4443 }
4444
4445 /// A UDP hop that has already **failed** does not sit out its head start: Go's `Race.Start`
4446 /// starts the fallback the moment the first function returns an error, and so does this.
4447 #[tokio::test(start_paused = true)]
4448 async fn a_failed_udp_hop_starts_tcp_at_once() {
4449 let query = build_query(0x40B, &["example", "com"], 1, 1);
4450 let upstream = upstream_addr(1);
4451 let over_tcp = upstream_response(&query, 0, 1, b"answered over tcp");
4452 let started = tokio::time::Instant::now();
4453
4454 let answer = race_udp_and_tcp(
4455 upstream,
4456 &query,
4457 ClientTransport::Udp,
4458 TcpRetry::Enabled,
4459 // Bind or send failed: nothing will come back.
4460 || std::future::ready(None),
4461 || std::future::ready(Some(over_tcp.clone())),
4462 )
4463 .await
4464 .expect("the TCP hop answered");
4465
4466 assert_eq!(answer.resp, over_tcp);
4467 assert_eq!(answer.via, UpstreamTransport::Tcp);
4468 assert_eq!(
4469 started.elapsed(),
4470 Duration::ZERO,
4471 "no head start is waited out for a hop that already failed"
4472 );
4473
4474 // And when both hops fail there is no answer, so the walk moves on.
4475 let answer = race_udp_and_tcp(
4476 upstream,
4477 &query,
4478 ClientTransport::Udp,
4479 TcpRetry::Enabled,
4480 || std::future::ready(None),
4481 || std::future::ready(None),
4482 )
4483 .await;
4484 assert!(answer.is_err(), "both hops failed: nothing to relay");
4485 }
4486
4487 /// The race is symmetric: a TCP hop that fails first leaves it to the UDP hop, which is waited
4488 /// for rather than abandoned.
4489 #[tokio::test(start_paused = true)]
4490 async fn a_failed_tcp_hop_leaves_the_race_to_the_udp_hop() {
4491 let query = build_query(0x40C, &["example", "com"], 1, 1);
4492 let upstream = upstream_addr(1);
4493 let over_udp = upstream_response(&query, 0, 1, b"answered over udp");
4494
4495 let answer = race_udp_and_tcp(
4496 upstream,
4497 &query,
4498 ClientTransport::Tcp,
4499 TcpRetry::Enabled,
4500 async || {
4501 tokio::time::sleep(Duration::from_secs(1)).await;
4502 Some((upstream, over_udp.clone()))
4503 },
4504 || std::future::ready(None),
4505 )
4506 .await
4507 .expect("the UDP hop answered after the TCP hop failed");
4508
4509 assert_eq!(answer.resp, over_udp);
4510 assert_eq!(answer.via, UpstreamTransport::Udp);
4511 }
4512
4513 /// The race sits inside the walk, not beside it: a first resolver that is silent over UDP but
4514 /// answers over TCP is the resolver that answers. The walk never reaches the second one, and the
4515 /// client has its answer after the head start, not after the UDP hop's full timeout.
4516 #[tokio::test(start_paused = true)]
4517 async fn a_resolver_silent_over_udp_answers_the_walk_over_tcp() {
4518 let query = build_query(0x40D, &["example", "com"], 1, 1);
4519 let first = upstream_addr(1);
4520 let second = upstream_addr(2);
4521 let over_tcp = upstream_response(&query, 0, 1, b"the first resolver over tcp");
4522 let started = tokio::time::Instant::now();
4523
4524 let asked = std::cell::RefCell::new(Vec::new());
4525 let response = forward_walk(
4526 &[first, second],
4527 &query,
4528 servfail_response(),
4529 ClientTransport::Udp,
4530 |upstream| {
4531 asked.borrow_mut().push(upstream);
4532 let over_tcp = over_tcp.clone();
4533 race_udp_and_tcp(
4534 upstream,
4535 &query,
4536 ClientTransport::Udp,
4537 TcpRetry::Enabled,
4538 silent_udp_hop,
4539 move || std::future::ready(Some(over_tcp)),
4540 )
4541 },
4542 )
4543 .await;
4544
4545 assert_eq!(response, over_tcp);
4546 assert_eq!(
4547 asked.into_inner(),
4548 vec![first],
4549 "the resolver that answered over TCP ends the walk"
4550 );
4551 assert_eq!(started.elapsed(), UDP_RACE_TIMEOUT);
4552 }
4553
4554 /// The other way an answer did not fit: the upstream sent one this forwarder cannot relay in a
4555 /// single datagram ([`MAX_UPSTREAM_RESPONSE`]), with `TC` clear because it fit the resolver's
4556 /// own idea of a datagram. [`cap_response`] is about to cut it and set `TC` — so the retry has
4557 /// to fire on it too, or that name resolves only ever as a cut answer. Go detects the same
4558 /// case by reading into a `maxResponseBytes+1` buffer and setting `TC` on the reply it hands
4559 /// its own truncation check.
4560 #[tokio::test]
4561 async fn an_oversize_answer_is_retried_even_with_tc_clear() {
4562 let query = build_edns_query(0x407, &["big", "example", "com"], 16, 1, 4096);
4563 let upstream = upstream_addr(1);
4564 // The one oversize datagram the netstack's 4096-byte receive ring can deliver, TC clear.
4565 let mut full_ring = upstream_response(&query, 0, 1, b"");
4566 full_ring.resize(MAX_UPSTREAM_RESPONSE + 1, 0xAB);
4567 assert_eq!(
4568 full_ring[2] & 0x02,
4569 0,
4570 "the upstream did not mark it truncated"
4571 );
4572 let whole = upstream_response(&query, 0, 40, &[0xAB; 8000]);
4573
4574 for client in BOTH_CLIENTS {
4575 let answer = race_udp_and_tcp(
4576 upstream,
4577 &query,
4578 client,
4579 TcpRetry::Enabled,
4580 || std::future::ready(Some((upstream, full_ring.clone()))),
4581 || std::future::ready(Some(whole.clone())),
4582 )
4583 .await
4584 .expect("the upstream answered");
4585
4586 assert_eq!(
4587 answer.resp, whole,
4588 "{client:?}: an answer too big for one datagram is re-asked over TCP, TC bit or no \
4589 TC bit"
4590 );
4591 assert_eq!(answer.via, UpstreamTransport::Tcp);
4592 }
4593 }
4594
4595 /// `dns-forwarder-disable-tcp-retries` is the TCP hop's **off** switch, and the polarity is the
4596 /// whole point: a node control never set it on keeps using TCP. Read from the self node through
4597 /// the view, and honoured at the one place that would open the connection — on **every** arm of
4598 /// the race: not for a truncated answer, not for a silent UDP hop once its head start runs out,
4599 /// and not alongside UDP for a TCP client.
4600 #[tokio::test(start_paused = true)]
4601 async fn the_node_attribute_turns_the_tcp_retry_off() {
4602 let mut view = view_with_peer();
4603 assert_eq!(
4604 view.upstream_tcp_retry(),
4605 TcpRetry::Enabled,
4606 "no self node yet ⇒ the retry is on, because on is the default"
4607 );
4608
4609 let mut node = test_node();
4610 view.self_node = Some(node.clone());
4611 assert_eq!(
4612 view.upstream_tcp_retry(),
4613 TcpRetry::Enabled,
4614 "a self node without the attribute ⇒ still on"
4615 );
4616
4617 node.cap_map
4618 .insert("dns-forwarder-disable-tcp-retries".to_string(), vec![]);
4619 view.self_node = Some(node);
4620 assert_eq!(
4621 view.upstream_tcp_retry(),
4622 TcpRetry::Disabled,
4623 "the attribute control sets is what turns it off"
4624 );
4625
4626 let query = build_query(0x403, &["big", "example", "com"], 16, 1);
4627 let upstream = upstream_addr(1);
4628 let mut truncated = upstream_response(&query, 0, 1, b"the part that fit");
4629 truncated[2] |= 0x02; // TC
4630
4631 for client in BOTH_CLIENTS {
4632 // With it off, a truncated answer is relayed as it came — no TCP connection at all.
4633 let retried = std::cell::Cell::new(false);
4634 let answer = race_udp_and_tcp(
4635 upstream,
4636 &query,
4637 client,
4638 view.upstream_tcp_retry(),
4639 || std::future::ready(Some((upstream, truncated.clone()))),
4640 || {
4641 retried.set(true);
4642 std::future::ready(None)
4643 },
4644 )
4645 .await
4646 .expect("the upstream answered");
4647
4648 assert!(
4649 !retried.get(),
4650 "{client:?}: the attribute must stop the TCP retry being made at all"
4651 );
4652 assert_eq!(
4653 answer.resp, truncated,
4654 "{client:?}: relayed truncated, as control asked"
4655 );
4656 assert_eq!(answer.via, UpstreamTransport::Udp);
4657
4658 // And a UDP hop that never answers is waited out, not raced: no fallback either.
4659 let started = tokio::time::Instant::now();
4660 let raced = std::cell::Cell::new(false);
4661 let answer = race_udp_and_tcp(
4662 upstream,
4663 &query,
4664 client,
4665 view.upstream_tcp_retry(),
4666 silent_udp_hop,
4667 || {
4668 raced.set(true);
4669 std::future::ready(Some(truncated.clone()))
4670 },
4671 )
4672 .await;
4673
4674 assert!(
4675 !raced.get(),
4676 "{client:?}: the attribute must keep the TCP hop out of the race too"
4677 );
4678 assert!(answer.is_err(), "{client:?}: UDP said nothing, so nothing");
4679 assert_eq!(
4680 started.elapsed(),
4681 UPSTREAM_TIMEOUT,
4682 "{client:?}: the UDP hop runs to its own timeout, as it did before the race"
4683 );
4684 }
4685 }
4686
4687 /// An off-path injector must not be able to buy a TCP connection to a resolver with a datagram
4688 /// it forged. A `TC`-marked datagram from the wrong source, or one that does not echo the
4689 /// question we asked, is relayed to [`forward_walk`] exactly as before — which discards it — and
4690 /// costs no connection on the way.
4691 #[tokio::test]
4692 async fn a_forged_truncated_datagram_does_not_buy_a_tcp_connection() {
4693 let query = build_query(0x404, &["example", "com"], 1, 1);
4694 let upstream = upstream_addr(1);
4695
4696 let retried = std::cell::Cell::new(false);
4697 let mut tcp_hop = || {
4698 retried.set(true);
4699 std::future::ready(None)
4700 };
4701
4702 let mut from_elsewhere = upstream_response(&query, 0, 1, b"injected");
4703 from_elsewhere[2] |= 0x02; // TC
4704 let answer = race_udp_and_tcp(
4705 upstream,
4706 &query,
4707 ClientTransport::Udp,
4708 TcpRetry::Enabled,
4709 || std::future::ready(Some((upstream_addr(9), from_elsewhere.clone()))),
4710 &mut tcp_hop,
4711 )
4712 .await
4713 .expect("the datagram is still handed on for the walk to discard");
4714 assert!(
4715 !retried.get(),
4716 "a datagram from the wrong source must not be retried"
4717 );
4718 assert_eq!(answer.via, UpstreamTransport::Udp);
4719
4720 let other_question = build_query(0x404, &["other", "example", "com"], 1, 1);
4721 let mut wrong_question = upstream_response(&other_question, 0, 1, b"injected");
4722 wrong_question[2] |= 0x02; // TC
4723 let answer = race_udp_and_tcp(
4724 upstream,
4725 &query,
4726 ClientTransport::Udp,
4727 TcpRetry::Enabled,
4728 || std::future::ready(Some((upstream, wrong_question.clone()))),
4729 &mut tcp_hop,
4730 )
4731 .await
4732 .expect("the datagram is still handed on for the walk to discard");
4733 assert!(
4734 !retried.get(),
4735 "a datagram answering another question must not be retried"
4736 );
4737 assert_eq!(answer.via, UpstreamTransport::Udp);
4738 }
4739
4740 /// Where the TCP hop is already running — a TCP client's, which starts with the UDP hop — a
4741 /// forged datagram does not end the race either: it is not an answer, so the resolver's own
4742 /// TCP answer is what comes back.
4743 #[tokio::test]
4744 async fn a_forged_datagram_does_not_cut_short_a_running_tcp_hop() {
4745 let query = build_query(0x40E, &["example", "com"], 1, 1);
4746 let upstream = upstream_addr(1);
4747 let injected = upstream_response(&query, 0, 1, b"injected");
4748 let over_tcp = upstream_response(&query, 0, 1, b"the resolver over tcp");
4749
4750 let answer = race_udp_and_tcp(
4751 upstream,
4752 &query,
4753 ClientTransport::Tcp,
4754 TcpRetry::Enabled,
4755 || std::future::ready(Some((upstream_addr(9), injected.clone()))),
4756 || std::future::ready(Some(over_tcp.clone())),
4757 )
4758 .await
4759 .expect("the TCP hop answered");
4760
4761 assert_eq!(answer.resp, over_tcp);
4762 assert_eq!(answer.via, UpstreamTransport::Tcp);
4763 }
4764
4765 /// The TCP hop gets the same scrutiny as the datagram, and for a reason the datagram check does
4766 /// not have: the hop that loses this race is **dropped**. A TCP message that does not echo the
4767 /// query is one `forward_walk` will discard, so letting it win takes the UDP answer down with
4768 /// it — the future is cancelled and the answer never arrives — and a query that resolves over
4769 /// UDP comes back SERVFAIL instead. Scored as a failed hop, the race stays with UDP.
4770 #[tokio::test(start_paused = true)]
4771 async fn a_mismatched_tcp_answer_leaves_the_race_to_the_udp_hop() {
4772 let query = build_query(0x410, &["example", "com"], 1, 1);
4773 let upstream = upstream_addr(1);
4774 let over_udp = upstream_response(&query, 0, 1, b"the resolver over udp");
4775 // A resolver answering a question we never asked, framed over the connection we opened for
4776 // this one: `response_matches_query` is what tells the two apart.
4777 let other_question = build_query(0x410, &["other", "example", "com"], 1, 1);
4778 let mismatched = upstream_response(&other_question, 0, 1, b"an answer to something else");
4779
4780 // A TCP client races both hops from the start, so the mismatched message is ready while the
4781 // UDP hop is still in flight — the case where winning would cost the answer.
4782 let answer = race_udp_and_tcp(
4783 upstream,
4784 &query,
4785 ClientTransport::Tcp,
4786 TcpRetry::Enabled,
4787 async || {
4788 tokio::time::sleep(Duration::from_millis(100)).await;
4789 Some((upstream, over_udp.clone()))
4790 },
4791 || std::future::ready(Some(mismatched.clone())),
4792 )
4793 .await
4794 .expect("the UDP hop's answer must survive a mismatched TCP message");
4795
4796 assert_eq!(
4797 answer.resp, over_udp,
4798 "the mismatched TCP message must not win a race the UDP hop was still running"
4799 );
4800 assert_eq!(answer.via, UpstreamTransport::Udp);
4801
4802 // And with nothing else running, a mismatched message is no answer at all: the walk moves
4803 // on to the next resolver rather than relaying it.
4804 let answer = race_udp_and_tcp(
4805 upstream,
4806 &query,
4807 ClientTransport::Udp,
4808 TcpRetry::Enabled,
4809 || std::future::ready(None),
4810 || std::future::ready(Some(mismatched.clone())),
4811 )
4812 .await;
4813
4814 assert!(
4815 answer.is_err(),
4816 "a failed UDP hop plus a mismatched TCP message is not an answer"
4817 );
4818 }
4819
4820 /// The truncation retry has the same hole and the worst outcome of it: a truncated answer is a
4821 /// real answer being *held*, and a mismatched TCP reply that replaced it would lose it outright
4822 /// — the same loss a failed TCP retry is already careful not to cause.
4823 #[tokio::test]
4824 async fn a_mismatched_tcp_retry_relays_the_truncated_udp_answer() {
4825 let query = build_query(0x411, &["big", "example", "com"], 16, 1);
4826 let upstream = upstream_addr(1);
4827 let mut truncated = upstream_response(&query, 0, 1, b"the part that fit");
4828 truncated[2] |= 0x02; // TC
4829 // Same question, wrong transaction id: a stale answer to the previous query on a reused
4830 // connection is the everyday way to get one of these without an attacker.
4831 let mut stale = upstream_response(&query, 0, 40, &[0xAB; 600]);
4832 stale[0] ^= 0xFF;
4833
4834 for client in BOTH_CLIENTS {
4835 let answer = race_udp_and_tcp(
4836 upstream,
4837 &query,
4838 client,
4839 TcpRetry::Enabled,
4840 || std::future::ready(Some((upstream, truncated.clone()))),
4841 || std::future::ready(Some(stale.clone())),
4842 )
4843 .await
4844 .expect("a mismatched retry must not lose the answer we already had");
4845
4846 assert_eq!(
4847 answer.resp, truncated,
4848 "{client:?}: the truncated UDP answer is relayed, not the mismatched TCP message"
4849 );
4850 assert_eq!(answer.via, UpstreamTransport::Udp, "{client:?}");
4851 }
4852 }
4853
4854 /// The other side of the fallback, in the shape of Go's `TestForwarderNetstackUpstream`, which
4855 /// bounds its elapsed time by `udpRaceTimeout`: a resolver whose UDP path works must be answered
4856 /// over UDP, inside [`UDP_RACE_TIMEOUT`], with no TCP hop started. The race would otherwise hide
4857 /// a broken UDP hop behind the right bytes arriving two seconds late over TCP — which is why the
4858 /// clock is checked, not only the bytes.
4859 #[tokio::test(start_paused = true)]
4860 async fn a_working_udp_hop_answers_inside_the_race_timeout_without_tcp() {
4861 let query = build_query(0x40F, &["example", "com"], 1, 1);
4862 let upstream = upstream_addr(1);
4863 let over_udp = upstream_response(&query, 0, 1, b"the resolver over udp");
4864 let started = tokio::time::Instant::now();
4865
4866 let tcp_started = std::cell::Cell::new(false);
4867 let answer = race_udp_and_tcp(
4868 upstream,
4869 &query,
4870 ClientTransport::Udp,
4871 TcpRetry::Enabled,
4872 async || {
4873 // A round trip to a resolver that is up.
4874 tokio::time::sleep(Duration::from_millis(50)).await;
4875 Some((upstream, over_udp.clone()))
4876 },
4877 || {
4878 tcp_started.set(true);
4879 std::future::ready(Some(b"never wanted".to_vec()))
4880 },
4881 )
4882 .await
4883 .expect("the upstream answered");
4884
4885 assert_eq!(answer.resp, over_udp);
4886 assert_eq!(answer.via, UpstreamTransport::Udp);
4887 assert!(
4888 started.elapsed() < UDP_RACE_TIMEOUT,
4889 "a working UDP path answers before TCP would ever be tried ({:?})",
4890 started.elapsed()
4891 );
4892 assert!(!tcp_started.get(), "and no TCP connection is opened");
4893 }
4894
4895 /// A SERVFAIL or REFUSED over UDP is a failed hop, not an answer: Go's `sendUDP` returns it as
4896 /// an `rcodeResponseError`, so `Race.Start` starts the TCP hop to the same resolver at once, and
4897 /// that hop's answer is the resolver's answer. Before this, the datagram won the race and the TCP
4898 /// hop was never tried, so a resolver that soft-fails over UDP but answers over TCP did not
4899 /// resolve here.
4900 #[tokio::test(start_paused = true)]
4901 async fn a_udp_soft_error_starts_the_tcp_hop_at_once_and_its_answer_wins() {
4902 let query = build_query(0x412, &["example", "com"], 1, 1);
4903 let upstream = upstream_addr(1);
4904 let over_tcp = upstream_response(&query, 0, 1, b"answered over tcp");
4905
4906 for rcode in [RCODE_SERVFAIL, RCODE_REFUSED] {
4907 let soft = upstream_response(&query, rcode, 0, b"soft over udp");
4908 for client in BOTH_CLIENTS {
4909 let started = tokio::time::Instant::now();
4910 let tcp_started_at = std::cell::Cell::new(None);
4911 let answer = race_udp_and_tcp(
4912 upstream,
4913 &query,
4914 client,
4915 TcpRetry::Enabled,
4916 || std::future::ready(Some((upstream, soft.clone()))),
4917 || {
4918 tcp_started_at.set(Some(started.elapsed()));
4919 std::future::ready(Some(over_tcp.clone()))
4920 },
4921 )
4922 .await
4923 .expect("the TCP hop answered");
4924
4925 assert_eq!(
4926 answer.resp, over_tcp,
4927 "rcode {rcode}, {client:?}: the TCP answer, not the soft error"
4928 );
4929 assert_eq!(answer.via, UpstreamTransport::Tcp);
4930 assert_eq!(answer.from, upstream, "the same resolver");
4931 assert_eq!(
4932 tcp_started_at.get(),
4933 Some(Duration::ZERO),
4934 "rcode {rcode}, {client:?}: no head start is waited out after a soft error"
4935 );
4936 }
4937 }
4938 }
4939
4940 /// When both hops soft-fail the resolver has failed, and the failure is Go's joined error read
4941 /// the way `forwardWithDestChan` reads it: refused if EITHER hop refused, and carrying the soft
4942 /// error of the hop that settled first.
4943 #[tokio::test]
4944 async fn both_hops_soft_failing_is_a_failure_carrying_the_first_soft_error() {
4945 let query = build_query(0x413, &["example", "com"], 1, 1);
4946 let upstream = upstream_addr(1);
4947 let servfail = upstream_response(&query, RCODE_SERVFAIL, 0, b"servfail over udp");
4948 let refused = upstream_response(&query, RCODE_REFUSED, 0, b"refused over tcp");
4949
4950 let failure = race_udp_and_tcp(
4951 upstream,
4952 &query,
4953 ClientTransport::Udp,
4954 TcpRetry::Enabled,
4955 || std::future::ready(Some((upstream, servfail.clone()))),
4956 || std::future::ready(Some(refused.clone())),
4957 )
4958 .await
4959 .expect_err("neither hop answered");
4960 assert!(
4961 failure.refused,
4962 "a REFUSED on either hop makes the resolver refused (errors.Is on the joined error)"
4963 );
4964 assert_eq!(
4965 failure.first_soft.map(|soft| soft.resp),
4966 Some(servfail.clone()),
4967 "the soft error kept is the first to settle — the UDP hop's SERVFAIL"
4968 );
4969
4970 // A soft error and a transport failure: the soft error is all there is to keep, and the
4971 // resolver is refused only if that soft error is a refusal.
4972 for (rcode, refused) in [(RCODE_SERVFAIL, false), (RCODE_REFUSED, true)] {
4973 let soft = upstream_response(&query, rcode, 0, b"soft over udp");
4974 let failure = race_udp_and_tcp(
4975 upstream,
4976 &query,
4977 ClientTransport::Udp,
4978 TcpRetry::Enabled,
4979 || std::future::ready(Some((upstream, soft.clone()))),
4980 || std::future::ready(None),
4981 )
4982 .await
4983 .expect_err("neither hop answered");
4984 assert_eq!(failure.refused, refused, "rcode {rcode}");
4985 assert_eq!(failure.first_soft.map(|soft| soft.resp), Some(soft));
4986 }
4987 }
4988
4989 /// The same rule on the other hop: a SERVFAIL or REFUSED over TCP (Go's `sendTCP` returns it as
4990 /// an `rcodeResponseError`) leaves the race to the UDP hop, which is waited for rather than
4991 /// beaten by the soft error.
4992 #[tokio::test(start_paused = true)]
4993 async fn a_tcp_soft_error_leaves_the_race_to_the_udp_hop() {
4994 let query = build_query(0x414, &["example", "com"], 1, 1);
4995 let upstream = upstream_addr(1);
4996 let over_udp = upstream_response(&query, 0, 1, b"answered over udp");
4997 let soft = upstream_response(&query, RCODE_SERVFAIL, 0, b"servfail over tcp");
4998
4999 // A TCP client races both hops from the start, so the TCP soft error is in while the UDP
5000 // hop is still in flight.
5001 let answer = race_udp_and_tcp(
5002 upstream,
5003 &query,
5004 ClientTransport::Tcp,
5005 TcpRetry::Enabled,
5006 async || {
5007 tokio::time::sleep(Duration::from_millis(100)).await;
5008 Some((upstream, over_udp.clone()))
5009 },
5010 || std::future::ready(Some(soft.clone())),
5011 )
5012 .await
5013 .expect("the UDP hop answered after the TCP hop soft-failed");
5014
5015 assert_eq!(answer.resp, over_udp);
5016 assert_eq!(answer.via, UpstreamTransport::Udp);
5017 }
5018
5019 /// A truncated answer held while TCP retries it is not given up for a soft error over TCP: Go's
5020 /// `send` finds the `truncatedResponseError` in the joined error and returns its bytes.
5021 #[tokio::test]
5022 async fn a_soft_tcp_retry_relays_the_truncated_udp_answer() {
5023 let query = build_query(0x415, &["big", "example", "com"], 16, 1);
5024 let upstream = upstream_addr(1);
5025 let mut truncated = upstream_response(&query, 0, 1, b"the part that fit");
5026 truncated[2] |= 0x02; // TC
5027 let soft = upstream_response(&query, RCODE_SERVFAIL, 0, b"servfail over tcp");
5028
5029 for client in BOTH_CLIENTS {
5030 let answer = race_udp_and_tcp(
5031 upstream,
5032 &query,
5033 client,
5034 TcpRetry::Enabled,
5035 || std::future::ready(Some((upstream, truncated.clone()))),
5036 || std::future::ready(Some(soft.clone())),
5037 )
5038 .await
5039 .expect("a soft-failed retry must not lose the answer we already had");
5040
5041 assert_eq!(answer.resp, truncated, "{client:?}");
5042 assert_eq!(answer.via, UpstreamTransport::Udp, "{client:?}");
5043 }
5044 }
5045
5046 /// With the TCP hop switched off, a soft error over UDP is still a failure and not an answer —
5047 /// and no connection is opened to find a better one.
5048 #[tokio::test]
5049 async fn with_tcp_off_a_udp_soft_error_is_a_failure_and_opens_no_connection() {
5050 let query = build_query(0x416, &["example", "com"], 1, 1);
5051 let upstream = upstream_addr(1);
5052 let refused = upstream_response(&query, RCODE_REFUSED, 0, b"refused over udp");
5053
5054 for client in BOTH_CLIENTS {
5055 let dialled = std::cell::Cell::new(false);
5056 let failure = race_udp_and_tcp(
5057 upstream,
5058 &query,
5059 client,
5060 TcpRetry::Disabled,
5061 || std::future::ready(Some((upstream, refused.clone()))),
5062 || {
5063 dialled.set(true);
5064 std::future::ready(None)
5065 },
5066 )
5067 .await
5068 .expect_err("a REFUSED is not an answer");
5069
5070 assert!(!dialled.get(), "{client:?}: TCP is off");
5071 assert!(failure.refused, "{client:?}");
5072 assert_eq!(
5073 failure.first_soft.map(|soft| soft.resp),
5074 Some(refused.clone())
5075 );
5076 }
5077 }
5078
5079 /// One scripted upstream for [`run_forward_walk_over_both_hops`]: its address, the datagram its
5080 /// UDP hop brings back, and the message its TCP hop brings back (`None` = nothing came back).
5081 type TwoHopUpstream = (SocketAddr, Option<Vec<u8>>, Option<Vec<u8>>);
5082
5083 /// Run the real [`forward_walk`] over the real [`race_udp_and_tcp`] for a UDP client, with both
5084 /// hops of each upstream scripted. Returns the client's bytes and the upstreams asked.
5085 async fn run_forward_walk_over_both_hops(
5086 script: &[TwoHopUpstream],
5087 query: &[u8],
5088 fallback: Vec<u8>,
5089 ) -> (Vec<u8>, Vec<SocketAddr>) {
5090 let upstreams: Vec<SocketAddr> = script.iter().map(|(upstream, ..)| *upstream).collect();
5091 let asked = std::cell::RefCell::new(Vec::new());
5092
5093 let response = forward_walk(
5094 &upstreams,
5095 query,
5096 fallback,
5097 ClientTransport::Udp,
5098 |upstream| {
5099 asked.borrow_mut().push(upstream);
5100 let (_, over_udp, over_tcp) = script
5101 .iter()
5102 .find(|(scripted, ..)| *scripted == upstream)
5103 .cloned()
5104 .expect("a scripted upstream");
5105 race_udp_and_tcp(
5106 upstream,
5107 query,
5108 ClientTransport::Udp,
5109 TcpRetry::Enabled,
5110 move || std::future::ready(over_udp.map(|resp| (upstream, resp))),
5111 move || std::future::ready(over_tcp),
5112 )
5113 },
5114 )
5115 .await;
5116
5117 (response, asked.into_inner())
5118 }
5119
5120 /// Consequence (1) of the audit: a resolver that soft-fails over UDP but answers over TCP
5121 /// resolves, and the walk never hands the query to a second resolver Go would not have asked.
5122 #[tokio::test]
5123 async fn a_resolver_that_soft_fails_over_udp_but_answers_over_tcp_ends_the_walk() {
5124 let query = build_query(0x417, &["example", "com"], 1, 1);
5125 let (first, second) = (upstream_addr(1), upstream_addr(2));
5126 let over_tcp = upstream_response(&query, 0, 1, b"the first resolver over tcp");
5127 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
5128
5129 for rcode in [RCODE_SERVFAIL, RCODE_REFUSED] {
5130 let (got, asked) = run_forward_walk_over_both_hops(
5131 &[
5132 (
5133 first,
5134 Some(upstream_response(&query, rcode, 0, b"soft over udp")),
5135 Some(over_tcp.clone()),
5136 ),
5137 (
5138 second,
5139 Some(upstream_response(&query, 0, 1, b"the second resolver")),
5140 None,
5141 ),
5142 ],
5143 &query,
5144 fallback.clone(),
5145 )
5146 .await;
5147
5148 assert_eq!(
5149 got, over_tcp,
5150 "rcode {rcode}: the first resolver's TCP answer"
5151 );
5152 assert_eq!(
5153 asked,
5154 vec![first],
5155 "rcode {rcode}: the second is never asked"
5156 );
5157 }
5158 }
5159
5160 /// Consequence (2): the refused tally counts a refusal on either hop. The second resolver's UDP
5161 /// SERVFAIL is followed by a TCP REFUSED, which Go counts as refused, so every resolver refused
5162 /// and the first refusal is relayed. Scored on the UDP datagram alone, the SERVFAIL made it a
5163 /// non-refusal and the client got the synthesized SERVFAIL instead.
5164 #[tokio::test]
5165 async fn a_refusal_on_either_hop_counts_toward_every_upstream_refusing() {
5166 let query = build_query(0x418, &["example", "com"], 1, 1);
5167 let (first, second) = (upstream_addr(1), upstream_addr(2));
5168 let first_refusal = upstream_response(&query, RCODE_REFUSED, 0, b"first refusal");
5169 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
5170
5171 let (got, asked) = run_forward_walk_over_both_hops(
5172 &[
5173 (first, Some(first_refusal.clone()), None),
5174 (
5175 second,
5176 Some(upstream_response(
5177 &query,
5178 RCODE_SERVFAIL,
5179 0,
5180 b"servfail over udp",
5181 )),
5182 Some(upstream_response(
5183 &query,
5184 RCODE_REFUSED,
5185 0,
5186 b"refused over tcp",
5187 )),
5188 ),
5189 ],
5190 &query,
5191 fallback.clone(),
5192 )
5193 .await;
5194
5195 assert_eq!(asked, vec![first, second]);
5196 assert_eq!(got, first_refusal, "every resolver was refused on some hop");
5197 assert_ne!(got, fallback);
5198 }
5199
5200 /// And the relayed bytes are the first soft error to settle, whatever its code: a lone resolver
5201 /// that answers SERVFAIL over UDP and REFUSED over TCP is refused, and Go relays the SERVFAIL
5202 /// it found first in the joined error.
5203 #[tokio::test]
5204 async fn an_all_refused_forward_relays_the_first_soft_error_to_settle() {
5205 let query = build_query(0x419, &["example", "com"], 1, 1);
5206 let only = upstream_addr(1);
5207 let servfail = upstream_response(&query, RCODE_SERVFAIL, 0, b"servfail over udp");
5208 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
5209
5210 let (got, _asked) = run_forward_walk_over_both_hops(
5211 &[(
5212 only,
5213 Some(servfail.clone()),
5214 Some(upstream_response(
5215 &query,
5216 RCODE_REFUSED,
5217 0,
5218 b"refused over tcp",
5219 )),
5220 )],
5221 &query,
5222 fallback,
5223 )
5224 .await;
5225
5226 assert_eq!(got, servfail);
5227 }
5228
5229 /// The relay cap is a datagram bound, so the one path it must not chop is the one that never
5230 /// touches a datagram: fetched over the TCP hop, handed to a TCP client. Chopping there would
5231 /// hand the stub resolver the same truncated answer it retried over TCP to escape, and the name
5232 /// would still not resolve through this node.
5233 ///
5234 /// The same answer on its way to a **UDP** client is still cut to one datagram and marked `TC`
5235 /// — it has to fit the datagram it leaves in — which is what sends that client to
5236 /// `dns_over_tcp`, where it is served whole.
5237 #[test]
5238 fn a_tcp_fetched_answer_is_relayed_whole_to_a_tcp_client() {
5239 let query = build_edns_query(0x405, &["big", "example", "com"], 16, 1, 4096);
5240 let mut big = query.clone();
5241 big[2] |= 0x80; // QR = 1
5242 big.resize(MAX_UPSTREAM_RESPONSE + 5000, 0xAB);
5243
5244 let out = cap_response(
5245 &query,
5246 big.clone(),
5247 ClientTransport::Tcp,
5248 UpstreamTransport::Tcp,
5249 );
5250 assert_eq!(
5251 out, big,
5252 "TCP end to end: the whole answer, unchopped and unmarked"
5253 );
5254
5255 let out = cap_response(&query, big, ClientTransport::Udp, UpstreamTransport::Tcp);
5256 assert_eq!(
5257 out.len(),
5258 MAX_UPSTREAM_RESPONSE,
5259 "the same answer still has to fit the datagram a UDP client is answered in"
5260 );
5261 assert_ne!(
5262 out[2] & 0x02,
5263 0,
5264 "and is marked truncated, which is what sends that client to the TCP responder"
5265 );
5266 }
5267
5268 /// The TCP hop's framing, driven end to end over an in-memory stream: the query goes out under
5269 /// a two-byte big-endian length prefix (RFC 1035 §4.2.2) in ONE write, and the answer is read
5270 /// back out from under its own prefix. Framing is what a DNS-over-TCP resolver rejects a
5271 /// connection for, so it is checked against a reader rather than asserted about.
5272 #[tokio::test]
5273 async fn tcp_exchange_frames_the_query_and_reads_the_framed_answer() {
5274 let query = build_query(0x406, &["big", "example", "com"], 16, 1);
5275 let answer = upstream_response(&query, 0, 40, &[0xAB; 8000]);
5276
5277 let (mut client, mut server) = tokio::io::duplex(64 * 1024);
5278 let resolver = {
5279 let query = query.clone();
5280 let answer = answer.clone();
5281 tokio::spawn(async move {
5282 let mut len_buf = [0u8; 2];
5283 server.read_exact(&mut len_buf).await.unwrap();
5284 let len = usize::from(u16::from_be_bytes(len_buf));
5285 assert_eq!(len, query.len(), "the prefix declares the query's length");
5286 let mut got = vec![0u8; len];
5287 server.read_exact(&mut got).await.unwrap();
5288 assert_eq!(got, query, "and the query follows it verbatim");
5289
5290 let mut framed = u16::try_from(answer.len()).unwrap().to_be_bytes().to_vec();
5291 framed.extend_from_slice(&answer);
5292 server.write_all(&framed).await.unwrap();
5293 })
5294 };
5295
5296 let got = tcp_exchange(&mut client, &query)
5297 .await
5298 .expect("a well-framed answer is read back");
5299 resolver
5300 .await
5301 .expect("the resolver side saw a framed query");
5302
5303 assert_eq!(
5304 got, answer,
5305 "the answer comes back whole — 8000 bytes no datagram here could have carried"
5306 );
5307 }
5308
5309 /// The `TC` bit a truncated UDP answer sets is what sends a stub resolver to TCP (RFC 1035
5310 /// §4.2.1). Setting it *again* on the TCP answer sends that resolver straight back into another
5311 /// retry, so the client's advertised UDP payload size — a property of the datagram it would
5312 /// have been answered in, and one RFC 7766 §8 gives a TCP client no equivalent of — is applied
5313 /// only to a [`ClientTransport::Udp`] client. Same query, same answer, two transports.
5314 #[test]
5315 fn client_udp_limit_is_not_applied_to_a_tcp_client() {
5316 // No EDNS OPT record, so the client's limit is the classic 512 bytes.
5317 let query = build_query(0x310, &["example", "com"], 1, 1);
5318 let mut answer = query.clone();
5319 answer[2] |= 0x80; // make it a response (QR=1)
5320 answer.resize(900, 0xAB); // over 512, under MAX_UPSTREAM_RESPONSE: only the client limit bites
5321
5322 let udp = cap_response(
5323 &query,
5324 answer.clone(),
5325 ClientTransport::Udp,
5326 UpstreamTransport::Udp,
5327 );
5328 assert_ne!(
5329 udp[2] & 0x02,
5330 0,
5331 "a UDP client that advertised 512 bytes is told the 900-byte answer is truncated"
5332 );
5333 assert_eq!(udp.len(), 900, "and the body is left intact either way");
5334
5335 let tcp = cap_response(&query, answer, ClientTransport::Tcp, UpstreamTransport::Udp);
5336 assert_eq!(
5337 tcp[2] & 0x02,
5338 0,
5339 "the same answer over TCP is NOT marked: the client already did the TCP retry"
5340 );
5341 assert_eq!(tcp.len(), 900, "and is relayed whole");
5342 }
5343
5344 /// The relay cap is a different claim from the client's datagram size, and for an answer that
5345 /// crossed the UDP hop it holds on both client transports: when [`MAX_UPSTREAM_RESPONSE`] really
5346 /// did cut the message, `TC` says so. Handing a TCP client a chopped body with `TC` clear would
5347 /// be a malformed-but-"complete" answer.
5348 #[test]
5349 fn a_chopped_answer_is_marked_truncated_on_both_transports() {
5350 let query = build_edns_query(0x311, &["example", "com"], 1, 1, 4096);
5351 let mut big = query.clone();
5352 big[2] |= 0x80;
5353 big.resize(MAX_UPSTREAM_RESPONSE + 500, 0xAB);
5354
5355 let out = cap_response(&query, big, ClientTransport::Tcp, UpstreamTransport::Udp);
5356 assert_eq!(out.len(), MAX_UPSTREAM_RESPONSE, "capped to one datagram");
5357 assert_ne!(
5358 out[2] & 0x02,
5359 0,
5360 "we really did chop the body, so TC is set for a TCP client too"
5361 );
5362 }
5363
5364 #[test]
5365 fn cap_response_sets_tc_when_truncated() {
5366 // An oversize upstream answer is capped to a single datagram AND marked truncated (TC bit)
5367 // so the stub resolver retries over TCP rather than trusting a chopped message. The query
5368 // advertises a big EDNS buffer so only the relay cap can be what fires here.
5369 let query = build_edns_query(0x300, &["example", "com"], 1, 1, 4096);
5370 let mut big = query.clone();
5371 big[2] |= 0x80; // make it a response (QR=1)
5372 big.resize(MAX_UPSTREAM_RESPONSE + 500, 0xAB);
5373
5374 let out = cap_response(&query, big, ClientTransport::Udp, UpstreamTransport::Udp);
5375 assert_eq!(out.len(), MAX_UPSTREAM_RESPONSE, "capped to one datagram");
5376 assert_ne!(out[2] & 0x02, 0, "TC bit set on truncation");
5377 }
5378
5379 #[test]
5380 fn cap_response_leaves_small_response_untouched() {
5381 // A response that fits both bounds is returned verbatim with no TC bit forced on.
5382 let query = build_query(0x301, &["example", "com"], 1, 1);
5383 let mut small = query.clone();
5384 small[2] |= 0x80;
5385 let before = small.clone();
5386
5387 let out = cap_response(&query, small, ClientTransport::Udp, UpstreamTransport::Udp);
5388 assert_eq!(out, before, "small response unchanged");
5389 assert_eq!(out[2] & 0x02, 0, "TC bit not set when no truncation");
5390 }
5391
5392 #[test]
5393 fn cap_is_a_relay_bound_not_the_read_bound() {
5394 // `forward_query` reads with `recv_from_bytes`, which issues `Recv { max_len: None }`, so
5395 // the netstack has already copied the whole datagram out before `cap_response` runs: the
5396 // cap bounds what we relay, not what we read or allocate. What bounds the read is the
5397 // netstack UDP socket's receive ring (`udp_buffer_size`, which `ts_runtime` leaves at the
5398 // `netcore` default) -- smoltcp drops a datagram larger than that ring at enqueue instead
5399 // of delivering it, and hands us everything up to and including the ring whole. The ring
5400 // being *wider* than the cap is what shows the two are different bounds: the read can put
5401 // more bytes in front of `cap_response` than the cap will relay.
5402 let ring = netstack::netcore::Config::default().udp_buffer_size;
5403 assert!(
5404 ring > MAX_UPSTREAM_RESPONSE,
5405 "the netstack udp receive ring ({ring}) no longer exceeds the relay cap \
5406 ({MAX_UPSTREAM_RESPONSE}): the cap would then be unreachable through this socket, and \
5407 the doc describing it as a relay bound the read can overrun is wrong"
5408 );
5409
5410 // The largest answer the cap passes is relayed byte-for-byte. Ask with an EDNS buffer that
5411 // covers the whole datagram, so the client-limit check (the other half of `cap_response`)
5412 // is not what we are measuring.
5413 let query = build_edns_query(0x302, &["example", "com"], 1, 1, 4096);
5414 let mut largest = query.clone();
5415 largest[2] |= 0x80; // QR=1
5416 largest.resize(MAX_UPSTREAM_RESPONSE, 0xAB);
5417 let before = largest.clone();
5418
5419 let out = cap_response(
5420 &query,
5421 largest,
5422 ClientTransport::Udp,
5423 UpstreamTransport::Udp,
5424 );
5425 assert_eq!(out, before, "an answer at the cap must be relayed verbatim");
5426 assert_eq!(
5427 out[2] & 0x02,
5428 0,
5429 "TC must not be set on a datagram that was never chopped"
5430 );
5431 }
5432
5433 #[test]
5434 fn full_ring_datagram_is_chopped_and_marked_truncated() {
5435 // Upstream's bound is `const maxResponseBytes = 4095` (net/dns/resolver/tsdns.go @
5436 // 9ea7cba44591e0cd840c6c94d23274dd222059bf). `sendUDP` reads into `maxResponseBytes+1`
5437 // bytes exactly so a 4096-byte answer is detectable as "did not fit", then cuts it to 4095
5438 // and sets TC. Here the netstack's 4096-byte receive ring plays the part of Go's `+1`: a
5439 // full-ring datagram is the one deliverable size the cap does not pass, and it must come
5440 // back with the same shape a Go forwarder would have produced. With the cap at 4096 this
5441 // datagram was relayed whole with TC clear, while a Go client on the same tailnet answering
5442 // the same query returned 4095 bytes marked truncated.
5443 let ring = netstack::netcore::Config::default().udp_buffer_size;
5444 let query = build_edns_query(0x303, &["example", "com"], 1, 1, 4096);
5445 let mut full_ring = query.clone();
5446 full_ring[2] |= 0x80; // QR=1
5447 full_ring.resize(ring, 0xAB);
5448
5449 let out = cap_response(
5450 &query,
5451 full_ring,
5452 ClientTransport::Udp,
5453 UpstreamTransport::Udp,
5454 );
5455 assert_eq!(
5456 out.len(),
5457 4095,
5458 "a full-ring answer must be cut to upstream's maxResponseBytes"
5459 );
5460 assert_ne!(out[2] & 0x02, 0, "TC bit set on the chopped answer");
5461 }
5462
5463 #[test]
5464 fn forwarded_reply_over_512_sets_tc_for_a_plain_query() {
5465 // A query with no EDNS OPT record is limited to 512 bytes (RFC 1035), so a 900-byte
5466 // forwarded reply -- well under the 4095 relay cap, and therefore relayed with TC clear
5467 // before this check existed -- must come back marked truncated, body intact.
5468 let query = build_query(0x400, &["example", "com"], 1, 1);
5469 let mut reply = query.clone();
5470 reply[2] |= 0x80; // QR=1
5471 reply.resize(900, 0xAB);
5472
5473 let out = cap_response(
5474 &query,
5475 reply.clone(),
5476 ClientTransport::Udp,
5477 UpstreamTransport::Udp,
5478 );
5479
5480 assert_ne!(
5481 out[2] & 0x02,
5482 0,
5483 "a 900-byte reply to a non-EDNS query must have TC set"
5484 );
5485 assert_eq!(out.len(), 900, "the body is left intact, not chopped");
5486 assert_eq!(
5487 out[3..],
5488 reply[3..],
5489 "only the flags byte carrying TC may differ"
5490 );
5491 }
5492
5493 #[test]
5494 fn forwarded_reply_under_advertised_edns_size_leaves_tc_clear() {
5495 // The same 900-byte reply, but the client advertised a 4096-byte EDNS buffer: it fits, so
5496 // TC must stay clear and the datagram must be relayed byte-for-byte.
5497 let query = build_edns_query(0x401, &["example", "com"], 1, 1, 4096);
5498 let mut reply = query.clone();
5499 reply[2] |= 0x80; // QR=1
5500 reply.resize(900, 0xAB);
5501 let before = reply.clone();
5502
5503 let out = cap_response(&query, reply, ClientTransport::Udp, UpstreamTransport::Udp);
5504
5505 assert_eq!(
5506 out, before,
5507 "a reply within the advertised buffer is verbatim"
5508 );
5509 assert_eq!(out[2] & 0x02, 0, "TC must stay clear");
5510 }
5511
5512 /// Go's `findOPTRecord` accepts an OPT record only in the final 11 bytes of the message, with a
5513 /// root NAME, EDNS version 0 and `RDLEN == 0`; anything else is "no EDNS", i.e. the 512-byte
5514 /// RFC 1035 limit. Every rejection below is a case where a laxer reader would honour a large
5515 /// advertised buffer and leave `TC` clear on an answer a Go node marks truncated.
5516 #[test]
5517 fn client_udp_limit_reads_the_opt_record() {
5518 // No OPT record => the RFC 1035 512-byte limit.
5519 let plain = build_query(0x402, &["example", "com"], 1, 1);
5520 assert_eq!(client_udp_limit(&plain), NO_EDNS_UDP_LIMIT);
5521
5522 // An OPT record's CLASS field carries the advertised size.
5523 let edns = build_edns_query(0x403, &["example", "com"], 1, 1, 1232);
5524 assert_eq!(client_udp_limit(&edns), 1232);
5525
5526 // A value below 512 is taken verbatim. RFC 6891 6.2.3 would floor it at 512, but Go does
5527 // not (`maxSize = int(ednsSize)`), so a Rust node that did would leave `TC` clear where a
5528 // Go node on the same tailnet sets it.
5529 let tiny = build_edns_query(0x404, &["example", "com"], 1, 1, 64);
5530 assert_eq!(client_udp_limit(&tiny), 64);
5531
5532 // An OPT record that is not the last record in the message is not read at all: upstream
5533 // only ever looks at the final 11 bytes.
5534 let mut trailing_rr = build_edns_query(0x405, &["example", "com"], 1, 1, 2048);
5535 // A 1-byte-RDATA TXT (type 16) record for the root name, appended after the OPT.
5536 trailing_rr.extend_from_slice(&[0, 0, 16, 0, 1, 0, 0, 0, 0, 0, 1, 0]);
5537 trailing_rr[11] = 2; // ARCOUNT = 2
5538 assert_eq!(client_udp_limit(&trailing_rr), NO_EDNS_UDP_LIMIT);
5539
5540 // An OPT record carrying options — a DNS cookie, EDNS Client Subnet — has RDLEN != 0 and is
5541 // rejected. This is the common case, not a corner: stub resolvers send cookies routinely.
5542 let cookie =
5543 build_edns_query_with_option(0x406, &["example", "com"], 1, 1, 4096, 10, &[0; 8]);
5544 assert_eq!(client_udp_limit(&cookie), NO_EDNS_UDP_LIMIT);
5545
5546 // An unknown EDNS version is left alone rather than guessed at.
5547 let mut future_version = build_edns_query(0x407, &["example", "com"], 1, 1, 4096);
5548 let ttl_at = future_version.len() - 6; // TTL = extended RCODE (1) | VERSION (1) | flags (2)
5549 future_version[ttl_at + 1] = 1; // EDNS version 1
5550 assert_eq!(client_udp_limit(&future_version), NO_EDNS_UDP_LIMIT);
5551
5552 // A non-root OPT NAME is rejected.
5553 let mut named = build_edns_query(0x408, &["example", "com"], 1, 1, 4096);
5554 let name_at = named.len() - 11;
5555 named[name_at] = 0xC0; // a compression pointer where the root label must be
5556 assert_eq!(client_udp_limit(&named), NO_EDNS_UDP_LIMIT);
5557
5558 // ARCOUNT == 0 means there is no additional section to hold an OPT, whatever the trailing
5559 // bytes happen to look like.
5560 let mut no_ar = build_edns_query(0x409, &["example", "com"], 1, 1, 4096);
5561 no_ar[11] = 0;
5562 assert_eq!(client_udp_limit(&no_ar), NO_EDNS_UDP_LIMIT);
5563
5564 // A truncated message falls back to the conservative limit, never a larger one.
5565 let mut chopped = build_edns_query(0x40A, &["example", "com"], 1, 1, 4096);
5566 chopped.truncate(chopped.len() - 8);
5567 assert_eq!(client_udp_limit(&chopped), NO_EDNS_UDP_LIMIT);
5568 }
5569
5570 /// The whole point of the narrow OPT reader, end to end: a stub resolver that advertises 4096
5571 /// **and** sends a DNS cookie is capped at 512, so the 900-byte forwarded reply comes back with
5572 /// `TC` set. A reader that walked the additional section properly would honour the 4096 and
5573 /// leave `TC` clear — which is the answer no Go node on the tailnet would have produced.
5574 #[test]
5575 fn an_opt_record_carrying_options_is_not_honoured() {
5576 let query =
5577 build_edns_query_with_option(0x40B, &["example", "com"], 1, 1, 4096, 10, &[0; 8]);
5578 let mut reply = query.clone();
5579 reply[2] |= 0x80; // QR=1
5580 reply.resize(900, 0xAB);
5581
5582 let out = cap_response(&query, reply, ClientTransport::Udp, UpstreamTransport::Udp);
5583 assert_ne!(
5584 out[2] & 0x02,
5585 0,
5586 "an OPT record with options is no EDNS at all upstream: the 512-byte limit applies"
5587 );
5588 assert_eq!(out.len(), 900, "the body is left intact, not chopped");
5589 }
5590
5591 /// An advertised size below 512 is honoured as-is. Go floors nothing: `maxSize = int(ednsSize)`
5592 /// whenever an OPT record is present, and only a request with no OPT record falls back to 512.
5593 #[test]
5594 fn an_advertised_size_below_512_is_not_floored() {
5595 let query = build_edns_query(0x40C, &["example", "com"], 1, 1, 200);
5596 let mut reply = query.clone();
5597 reply[2] |= 0x80; // QR=1
5598 reply.resize(300, 0xAB);
5599
5600 let out = cap_response(&query, reply, ClientTransport::Udp, UpstreamTransport::Udp);
5601 assert_ne!(
5602 out[2] & 0x02,
5603 0,
5604 "300 bytes overflows the 200 the client asked for, so TC is set"
5605 );
5606 assert_eq!(out.len(), 300, "the body is left intact, not chopped");
5607 }
5608
5609 /// Upstream runs the size check on answers the resolver builds itself, not only on forwarded
5610 /// ones (`Resolver.Query` calls `checkResponseSizeAndSetTC` right after `respond` succeeds). An
5611 /// authoritative answer is capped at 512 bytes, which says nothing about a client that
5612 /// advertised less than that.
5613 #[test]
5614 fn an_authoritative_answer_over_the_advertised_size_is_marked() {
5615 let view = view_with_peer();
5616 let buf = build_edns_query(0x40D, &["host", "user", "ts", "net"], 1, 1, 20);
5617
5618 let resp = answer(&view, &buf).expect("answers");
5619 assert!(
5620 resp.len() > 20,
5621 "the fixture only works if the answer overflows the advertised 20 bytes"
5622 );
5623
5624 let marked = check_response_size_and_set_tc(&buf, resp.clone(), ClientTransport::Udp);
5625 assert_ne!(
5626 marked[2] & 0x02,
5627 0,
5628 "an answer we composed ourselves can still overflow a small advertised buffer"
5629 );
5630 assert_eq!(marked.len(), resp.len(), "the body is left intact");
5631 assert_eq!(
5632 marked[3..],
5633 resp[3..],
5634 "only the flags byte carrying TC may differ"
5635 );
5636 }
5637
5638 #[test]
5639 fn response_matches_query_rejects_mismatched_question() {
5640 // id + QR match but the echoed question differs (different QNAME) => rejected. This guards
5641 // against an off-path injector that guesses the id but answers a different question.
5642 let query = build_query(0x1234, &["a", "com"], 1, 1);
5643
5644 let mut wrong_question = build_query(0x1234, &["b", "com"], 1, 1);
5645 wrong_question[2] |= 0x80; // QR=1, same id
5646 assert!(
5647 !response_matches_query(&query, &wrong_question),
5648 "different QNAME must be rejected"
5649 );
5650
5651 // A different QTYPE with the same name is also rejected.
5652 let mut wrong_qtype = build_query(0x1234, &["a", "com"], 28, 1);
5653 wrong_qtype[2] |= 0x80;
5654 assert!(
5655 !response_matches_query(&query, &wrong_qtype),
5656 "different QTYPE must be rejected"
5657 );
5658
5659 // The exact echoed question with QR=1 is accepted.
5660 let mut good = query.clone();
5661 good[2] |= 0x80;
5662 assert!(
5663 response_matches_query(&query, &good),
5664 "matching question accepted"
5665 );
5666 }
5667
5668 #[test]
5669 fn suffix_matches_handles_boundaries_and_empty() {
5670 // Exact and label-boundary matches.
5671 assert!(suffix_matches("corp", "corp"));
5672 assert!(suffix_matches("a.corp", "corp"));
5673 assert!(suffix_matches("a.b.corp", "corp"));
5674 // Not a label boundary.
5675 assert!(!suffix_matches("acorp", "corp"));
5676 // Empty suffix never matches (defense-in-depth against `ends_with("")`).
5677 assert!(!suffix_matches("anything.example", ""));
5678 assert!(!suffix_matches("", ""));
5679 }
5680
5681 #[test]
5682 fn empty_search_domain_does_not_capture_everything() {
5683 // Defense-in-depth: an empty search domain must NOT make every name look like a tailnet
5684 // name (which would fail-close legitimate recursive queries / mis-route). With an empty
5685 // suffix present alongside a real resolver, an off-tailnet name still forwards.
5686 let mut view = view_with_routes(
5687 std::collections::BTreeMap::new(),
5688 vec![udp("8.8.8.8:53")],
5689 vec![],
5690 );
5691 view.cfg.search_domains = vec![String::new()];
5692 let buf = build_query(0x400, &["example", "com"], 1, 1);
5693
5694 match decide(&view, &buf).expect("decides") {
5695 Decision::Forward { upstreams, .. } => {
5696 assert_eq!(upstreams, vec!["8.8.8.8:53".parse().unwrap()]);
5697 }
5698 Decision::Reply(_) => {
5699 panic!("empty search domain must not treat every name as tailnet")
5700 }
5701 }
5702 }
5703
5704 #[test]
5705 fn empty_route_suffix_does_not_capture_everything() {
5706 // Defense-in-depth: an empty route suffix must not match every name (which would route all
5707 // queries to that route's upstreams). With an empty-suffix route present, an unrelated name
5708 // still falls through to the global resolver.
5709 let mut routes = std::collections::BTreeMap::new();
5710 routes.insert(String::new(), vec![udp("10.9.9.9:53")]);
5711 let view = view_with_routes(routes, vec![udp("8.8.8.8:53")], vec![]);
5712 let buf = build_query(0x401, &["example", "com"], 1, 1);
5713
5714 match decide(&view, &buf).expect("decides") {
5715 Decision::Forward { upstreams, .. } => {
5716 assert_eq!(
5717 upstreams,
5718 vec!["8.8.8.8:53".parse().unwrap()],
5719 "empty route suffix must not capture; falls through to global"
5720 );
5721 }
5722 Decision::Reply(_) => panic!("expected forward to global resolver"),
5723 }
5724 }
5725
5726 fn udp_exit(addr: &str) -> DnsResolver {
5727 DnsResolver {
5728 transport: ts_control::ResolverTransport::Udp(addr.parse().unwrap()),
5729 use_with_exit_node: true,
5730 }
5731 }
5732
5733 #[test]
5734 fn recursive_forward_is_flagged_route_forward_is_not() {
5735 // A recursive (global/fallback) forward sets `recursive = true` (eligible for DoH
5736 // delegation); a deliberately-configured split-DNS route sets `recursive = false`.
5737 let mut routes = std::collections::BTreeMap::new();
5738 routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
5739 let view = view_with_routes(routes, vec![udp("8.8.8.8:53")], vec![]);
5740
5741 let routed = build_query(0x500, &["api", "corp", "example"], 1, 1);
5742 match decide(&view, &routed).expect("decides") {
5743 Decision::Forward { recursive, .. } => {
5744 assert!(!recursive, "split-DNS route is not a recursive forward")
5745 }
5746 Decision::Reply(_) => panic!("expected route forward"),
5747 }
5748
5749 let global = build_query(0x501, &["example", "com"], 1, 1);
5750 match decide(&view, &global).expect("decides") {
5751 Decision::Forward { recursive, .. } => {
5752 assert!(recursive, "unrouted name is a recursive forward")
5753 }
5754 Decision::Reply(_) => panic!("expected recursive forward"),
5755 }
5756 }
5757
5758 #[test]
5759 fn recursive_plan_keeps_udp_without_exit_node() {
5760 // No active exit node: a recursive forward stays on its default UDP upstreams.
5761 let view = view_with_routes(
5762 std::collections::BTreeMap::new(),
5763 vec![udp("8.8.8.8:53")],
5764 vec![],
5765 );
5766 let default = vec!["8.8.8.8:53".parse().unwrap()];
5767 assert_eq!(
5768 recursive_plan(&view, default.clone()),
5769 RecursivePlan::Udp(default)
5770 );
5771 }
5772
5773 #[test]
5774 fn recursive_plan_delegates_to_doh_with_exit_node() {
5775 // Exit node active, no kept-local resolvers: recursive queries delegate to the exit node's
5776 // DoH endpoint so resolution egresses from the exit node, not this host.
5777 let mut view = view_with_routes(
5778 std::collections::BTreeMap::new(),
5779 vec![udp("8.8.8.8:53")],
5780 vec![],
5781 );
5782 let doh: SocketAddr = "100.64.0.5:8080".parse().unwrap();
5783 view.exit_doh = Some(doh);
5784 assert_eq!(
5785 recursive_plan(&view, vec!["8.8.8.8:53".parse().unwrap()]),
5786 RecursivePlan::Doh(doh)
5787 );
5788 }
5789
5790 #[test]
5791 fn recursive_plan_keeps_use_with_exit_node_resolvers_local() {
5792 // Even with an exit node active, resolvers flagged `use_with_exit_node` stay local (Go keeps
5793 // UseWithExitNode resolvers). The plan forwards to those over UDP, never delegating to DoH.
5794 let mut view = view_with_routes(
5795 std::collections::BTreeMap::new(),
5796 vec![udp_exit("10.0.0.53:53"), udp("8.8.8.8:53")],
5797 vec![],
5798 );
5799 view.exit_doh = Some("100.64.0.5:8080".parse().unwrap());
5800 // The default upstreams the caller computed are irrelevant when kept-local resolvers exist;
5801 // the plan must use the kept-local ones.
5802 assert_eq!(
5803 recursive_plan(&view, vec!["8.8.8.8:53".parse().unwrap()]),
5804 RecursivePlan::Udp(vec!["10.0.0.53:53".parse().unwrap()])
5805 );
5806 }
5807
5808 // --- SOA on authoritative negative answers (RFC 2308) -----------------------------------
5809
5810 /// Read an uncompressed name at `off`, returning it dotted and the offset just past it.
5811 fn read_name(resp: &[u8], mut off: usize) -> (String, usize) {
5812 let mut labels: Vec<String> = Vec::new();
5813 loop {
5814 let len = resp[off] as usize;
5815 assert_eq!(len & 0xC0, 0, "no compression pointer expected here");
5816 off += 1;
5817 if len == 0 {
5818 break;
5819 }
5820 labels.push(String::from_utf8(resp[off..off + len].to_vec()).expect("ascii label"));
5821 off += len;
5822 }
5823 (labels.join("."), off)
5824 }
5825
5826 /// The number of records in a response's authority section (NSCOUNT).
5827 fn nscount(resp: &[u8]) -> u16 {
5828 u16::from_be_bytes([resp[8], resp[9]])
5829 }
5830
5831 /// Walk an answer-less response to its authority section and read the SOA there, returning
5832 /// `(zone, record TTL, SERIAL, MINIMUM)`. `None` when the authority section is empty.
5833 ///
5834 /// Also asserts the record's shape as it goes: TYPE=SOA, CLASS=IN, and MNAME/RNAME both equal
5835 /// the owner name (the placeholders Go writes).
5836 fn parse_soa(resp: &[u8]) -> Option<(String, u32, u32, u32)> {
5837 let (.., ancount) = parse_header(resp);
5838 assert_eq!(ancount, 0, "parse_soa only walks answer-less responses");
5839 if nscount(resp) == 0 {
5840 return None;
5841 }
5842 assert_eq!(nscount(resp), 1, "at most one SOA");
5843
5844 // Question: QNAME then QTYPE + QCLASS.
5845 let (_, off) = read_name(resp, 12);
5846 // Authority record: NAME, TYPE, CLASS, TTL, RDLENGTH, RDATA.
5847 let (zone, off) = read_name(resp, off + 4);
5848 let u16_at = |at: usize| u16::from_be_bytes([resp[at], resp[at + 1]]);
5849 let u32_at = |at: usize| u32::from_be_bytes(resp[at..at + 4].try_into().unwrap());
5850 assert_eq!(u16_at(off), 6, "TYPE = SOA");
5851 assert_eq!(u16_at(off + 2), 1, "CLASS = IN");
5852 let ttl = u32_at(off + 4);
5853 let rdlength = u16_at(off + 8) as usize;
5854
5855 // RDATA: MNAME, RNAME, SERIAL, REFRESH, RETRY, EXPIRE, MINIMUM.
5856 let rdata_start = off + 10;
5857 let (mname, off) = read_name(resp, rdata_start);
5858 let (rname, off) = read_name(resp, off);
5859 assert_eq!(mname, zone, "MNAME is the zone (placeholder)");
5860 assert_eq!(rname, zone, "RNAME is the zone (placeholder)");
5861 let serial = u32_at(off);
5862 let minimum = u32_at(off + 16);
5863 assert_eq!(
5864 off + 20 - rdata_start,
5865 rdlength,
5866 "RDLENGTH covers exactly the SOA fields"
5867 );
5868 assert_eq!(resp.len(), off + 20, "the SOA is the last record");
5869 Some((zone, ttl, serial, minimum))
5870 }
5871
5872 /// Roughly-now, for asserting the SOA SERIAL is a unix timestamp rather than a constant.
5873 fn now_unix() -> u32 {
5874 std::time::SystemTime::now()
5875 .duration_since(std::time::UNIX_EPOCH)
5876 .expect("clock after the epoch")
5877 .as_secs() as u32
5878 }
5879
5880 /// An NXDOMAIN for a name under a tailnet search domain is authoritative, so it carries that
5881 /// search domain's SOA with the 10-second negative TTL. Without it a downstream cache picks its
5882 /// own (much longer) negative lifetime and a node renamed to that name stays unresolvable.
5883 #[test]
5884 fn nxdomain_for_tailnet_name_carries_the_search_domain_soa() {
5885 let view = view_with_peer();
5886 let buf = build_query(0x1111, &["nope", "user", "ts", "net"], 1, 1);
5887
5888 let resp = answer(&view, &buf).expect("answers");
5889 let (_, rcode, ancount) = parse_header(&resp);
5890 assert_eq!(rcode, 3, "NXDOMAIN");
5891 assert_eq!(ancount, 0);
5892
5893 let (zone, ttl, serial, minimum) =
5894 parse_soa(&resp).expect("an SOA in the authority section");
5895 assert_eq!(zone, "user.ts.net", "the search domain containing the name");
5896 assert_eq!(ttl, 10, "negative TTL");
5897 assert_eq!(minimum, 10, "MINIMUM also bounds negative caching");
5898 // The serial is the response time in unix seconds, not a fixed placeholder.
5899 assert!(
5900 serial.abs_diff(now_unix()) < 60,
5901 "SERIAL should be about now, got {serial}"
5902 );
5903 }
5904
5905 /// A NODATA — the name exists but we hold no address of the queried family, which is what an
5906 /// AAAA query for a peer becomes with the IPv6 gate off — is negative too, and takes the SOA.
5907 #[test]
5908 fn nodata_aaaa_for_known_peer_carries_the_soa() {
5909 let view = view_with_peer();
5910 assert!(!view.enable_ipv6, "default gate is off");
5911 let buf = build_query(0x2222, &["host", "user", "ts", "net"], 28, 1);
5912
5913 let resp = answer(&view, &buf).expect("answers");
5914 let (_, rcode, ancount) = parse_header(&resp);
5915 assert_eq!(rcode, 0, "NoError (NODATA)");
5916 assert_eq!(ancount, 0);
5917 let (zone, ttl, _, minimum) = parse_soa(&resp).expect("an SOA in the authority section");
5918 assert_eq!(zone, "user.ts.net");
5919 assert_eq!((ttl, minimum), (10, 10));
5920 }
5921
5922 /// A reverse query for an unmatched IP in the tailnet CGNAT range is authoritatively absent, so
5923 /// it carries the SOA of the reverse zone that covers it — the same per-/16 `in-addr.arpa`
5924 /// chunk real tailscaled advertises, not the search domain.
5925 #[test]
5926 fn cgnat_reverse_miss_carries_the_reverse_zone_soa() {
5927 let view = view_with_peer();
5928 // Reverse name for an unclaimed 100.64.0.0/10 address, least-significant octet first.
5929 let buf = build_query(0x3333, &["9", "0", "64", "100", "in-addr", "arpa"], 12, 1);
5930
5931 let resp = answer(&view, &buf).expect("answers");
5932 let (_, rcode, ancount) = parse_header(&resp);
5933 assert_eq!(rcode, 3, "NXDOMAIN");
5934 assert_eq!(ancount, 0);
5935 let (zone, ttl, _, minimum) = parse_soa(&resp).expect("an SOA in the authority section");
5936 assert_eq!(zone, "64.100.in-addr.arpa", "the CGNAT reverse zone");
5937 assert_eq!((ttl, minimum), (10, 10));
5938 }
5939
5940 /// The exotic-qtype path re-applies the CGNAT reverse guard, and its NXDOMAIN is just as
5941 /// authoritative — so it carries the same reverse-zone SOA the PTR arm does.
5942 #[test]
5943 fn exotic_qtype_cgnat_reverse_nxdomain_carries_the_soa() {
5944 let view = view_with_peer();
5945 // TXT (16) for a CGNAT reverse name.
5946 let buf = build_query(0x4444, &["9", "0", "64", "100", "in-addr", "arpa"], 16, 1);
5947
5948 let resp = answer(&view, &buf).expect("answers");
5949 assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
5950 let (zone, ..) = parse_soa(&resp).expect("an SOA in the authority section");
5951 assert_eq!(zone, "64.100.in-addr.arpa");
5952 }
5953
5954 /// A negative split-DNS route (a route with no resolvers) is Go's `localDomains` verbatim: the
5955 /// NXDOMAIN it produces is authoritative and names the route's own suffix as its zone.
5956 #[test]
5957 fn negative_route_nxdomain_carries_the_route_zone_soa() {
5958 let mut routes = std::collections::BTreeMap::new();
5959 routes.insert("corp.example".to_string(), vec![]);
5960 let view = view_with_routes(routes, vec![], vec![]);
5961 let buf = build_query(0x5555, &["intranet", "corp", "example"], 1, 1);
5962
5963 let resp = answer(&view, &buf).expect("answers");
5964 assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
5965 let (zone, ttl, _, minimum) = parse_soa(&resp).expect("an SOA in the authority section");
5966 assert_eq!(zone, "corp.example");
5967 assert_eq!((ttl, minimum), (10, 10));
5968 }
5969
5970 /// Answers we are NOT authoritative for carry no SOA: a SERVFAIL is a soft failure with nothing
5971 /// to cache, and an `ip6.arpa` NXDOMAIN is this fork's blanket anti-leak refusal, not a claim to
5972 /// serve the IPv6 reverse tree.
5973 #[test]
5974 fn non_authoritative_negative_answers_carry_no_soa() {
5975 let view = view_with_peer();
5976
5977 // Off-tailnet name, no upstream configured => SERVFAIL.
5978 let servfail =
5979 answer(&view, &build_query(0x6, &["example", "com"], 1, 1)).expect("answers");
5980 assert_eq!(parse_header(&servfail).1, 2, "ServFail");
5981 assert_eq!(nscount(&servfail), 0, "SERVFAIL carries no SOA");
5982
5983 // An ip6.arpa reverse name. The exact nibble labels do not matter to the guard.
5984 let mut labels: Vec<&str> = vec!["1"; 32];
5985 labels.push("ip6");
5986 labels.push("arpa");
5987 let ip6 = answer(&view, &build_query(0x7, &labels, 12, 1)).expect("answers");
5988 assert_eq!(parse_header(&ip6).1, 3, "NXDOMAIN");
5989 assert_eq!(nscount(&ip6), 0, "ip6.arpa NXDOMAIN carries no SOA");
5990
5991 // MagicDNS off => REFUSED, which asserts nothing about the name.
5992 let mut off = view_with_peer();
5993 off.cfg.magic_dns = false;
5994 let refused = answer(
5995 &off,
5996 &build_query(0x8, &["host", "user", "ts", "net"], 1, 1),
5997 )
5998 .expect("answers");
5999 assert_eq!(parse_header(&refused).1, 5, "Refused");
6000 assert_eq!(nscount(&refused), 0, "REFUSED carries no SOA");
6001 }
6002
6003 /// A NODATA for a type we simply do not serve on a name we do (TXT on a tailnet name) carries
6004 /// no SOA: Go sets `SOAZone` on a no-data answer only for an A/AAAA/ALL question.
6005 #[test]
6006 fn nodata_for_an_unserved_qtype_carries_no_soa() {
6007 let view = view_with_peer();
6008 let resp = answer(
6009 &view,
6010 &build_query(0x9, &["host", "user", "ts", "net"], 16, 1),
6011 )
6012 .expect("answers");
6013 let (_, rcode, ancount) = parse_header(&resp);
6014 assert_eq!((rcode, ancount), (0, 0), "NODATA");
6015 assert_eq!(nscount(&resp), 0);
6016 }
6017
6018 /// A positive answer has an empty authority section and a 5-second TTL. The short TTL is the
6019 /// positive half of the same argument: the netmap is local and in-memory, so a re-query is
6020 /// nearly free, while a downstream cache would otherwise hide a node rename for the full TTL.
6021 #[test]
6022 fn positive_answer_has_ttl_5_and_no_authority_section() {
6023 let view = view_with_peer();
6024 let resp = answer(
6025 &view,
6026 &build_query(0xA, &["host", "user", "ts", "net"], 1, 1),
6027 )
6028 .expect("answers");
6029 let (_, rcode, ancount) = parse_header(&resp);
6030 assert_eq!((rcode, ancount), (0, 1), "one A record");
6031 assert_eq!(nscount(&resp), 0, "a positive answer claims no zone");
6032 // The single A record's tail is TTL, RDLENGTH, RDATA.
6033 let ttl_at = resp.len() - 10;
6034 let ttl = u32::from_be_bytes(resp[ttl_at..ttl_at + 4].try_into().unwrap());
6035 assert_eq!(ttl, 5, "positive TTL");
6036 }
6037
6038 /// An authoritative negative answer with its SOA attached must still fit the classic 512-byte
6039 /// UDP limit, so the client-limit check leaves TC clear on it for a client that advertised no
6040 /// EDNS buffer. (A client that advertises *less* than 512 is a different case and is marked —
6041 /// see `an_authoritative_answer_over_the_advertised_size_is_marked`.)
6042 #[test]
6043 fn nxdomain_with_soa_stays_within_the_client_udp_limit() {
6044 let view = view_with_peer();
6045 let long = "a".repeat(63);
6046 let buf = build_query(0xB, &[&long, "user", "ts", "net"], 1, 1);
6047
6048 let resp = answer(&view, &buf).expect("answers");
6049 assert_eq!(nscount(&resp), 1, "the SOA fits beside this question");
6050 assert!(resp.len() <= 512, "still one classic UDP datagram");
6051
6052 let marked = check_response_size_and_set_tc(&buf, resp.clone(), ClientTransport::Udp);
6053 assert_eq!(marked, resp, "nothing to mark: an authoritative reply fits");
6054 assert_eq!(
6055 u16::from_be_bytes([marked[2], marked[3]]) & 0x0200,
6056 0,
6057 "TC must stay clear"
6058 );
6059 }
6060
6061 /// When the zone is so long that its SOA no longer fits under the 512-byte cap, the SOA is
6062 /// dropped rather than the answer being truncated: the NXDOMAIN goes back complete, with an
6063 /// empty authority section, TC clear, and still within a client's UDP limit. Losing the SOA
6064 /// only means a resolver falls back to its own negative-cache policy.
6065 #[test]
6066 fn an_soa_that_will_not_fit_is_dropped_and_the_nxdomain_still_answers() {
6067 let long = "a".repeat(63);
6068 let zone = [long.as_str(), long.as_str(), long.as_str()].join(".");
6069 let mut view = view_with_peer();
6070 view.cfg.search_domains = vec![zone.clone()];
6071
6072 let buf = build_query(0xC, &["x", &long, &long, &long], 1, 1);
6073 let resp = answer(&view, &buf).expect("answers");
6074
6075 assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
6076 assert_eq!(nscount(&resp), 0, "the SOA did not fit and was dropped");
6077 assert!(resp.len() <= 512, "response stays within the UDP limit");
6078 let marked = check_response_size_and_set_tc(&buf, resp.clone(), ClientTransport::Udp);
6079 assert_eq!(
6080 u16::from_be_bytes([marked[2], marked[3]]) & 0x0200,
6081 0,
6082 "a dropped SOA must not set TC: the fork cannot serve the TCP retry it would ask for"
6083 );
6084 }
6085
6086 /// The zone is the *longest* authoritative suffix containing the name, so a name under a
6087 /// sub-zone gets the sub-zone's SOA rather than the shorter search domain's.
6088 #[test]
6089 fn the_longest_authoritative_zone_wins() {
6090 let mut routes = std::collections::BTreeMap::new();
6091 routes.insert("sub.user.ts.net".to_string(), vec![]);
6092 let mut view = view_with_routes(routes, vec![], vec![]);
6093 view.cfg.search_domains = vec!["user.ts.net".to_string()];
6094
6095 let buf = build_query(0xD, &["nope", "sub", "user", "ts", "net"], 1, 1);
6096 let resp = answer(&view, &buf).expect("answers");
6097 assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
6098 let (zone, ..) = parse_soa(&resp).expect("an SOA in the authority section");
6099 assert_eq!(zone, "sub.user.ts.net");
6100 }
6101
6102 /// A name we resolved only by search-domain qualification (a short name like `host`) is not
6103 /// itself inside a zone we serve, so its negative answer names no zone — matching Go, whose
6104 /// `authoritativeZoneFor` is given the query name as asked.
6105 #[test]
6106 fn a_short_name_outside_every_zone_gets_no_soa() {
6107 let mut view = view_with_peer();
6108 view.enable_ipv6 = false;
6109 // `host` resolves to the peer via search-domain qualification, and with IPv6 off the AAAA
6110 // is a NODATA — but `host` sits under no zone we serve.
6111 let resp = answer(&view, &build_query(0xE, &["host"], 28, 1)).expect("answers");
6112 let (_, rcode, ancount) = parse_header(&resp);
6113 assert_eq!((rcode, ancount), (0, 0), "NODATA");
6114 assert_eq!(nscount(&resp), 0, "no zone contains a single-label name");
6115 }
6116}