Skip to main content

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                env.exit_host_resolver(),
2076                taildrop,
2077                funnel_ingress,
2078            ));
2079        }
2080
2081        Ok(Self {
2082            _joinset: joinset,
2083            view_tx,
2084            env,
2085            channel,
2086        })
2087    }
2088}
2089
2090/// A bare SERVFAIL response header for a [`Query`] whose name could not be encoded into a
2091/// well-formed query (a non-ASCII label or an over-255-byte name). A 12-byte header with QR=1 (this
2092/// is a response) and RCODE=2 (server failure); no question or answer section (we never produced a
2093/// parseable question). Lets `query_dns` return a definite, honest RCODE instead of an empty buffer
2094/// that would read back as a fabricated NoError.
2095fn servfail_response() -> Vec<u8> {
2096    let mut resp = vec![0u8; 12];
2097    // Flags: QR=1 (byte 2, 0x80) + RCODE=2 (low nibble of byte 3). All other bits clear.
2098    resp[2] = 0x80;
2099    resp[3] = 0x02;
2100    resp
2101}
2102
2103impl Message<Query> for MagicDnsActor {
2104    type Reply = DnsQueryResult;
2105
2106    async fn handle(&mut self, query: Query, _ctx: &mut Context<Self, Self::Reply>) -> Self::Reply {
2107        // Synthesize a query packet and drive it through the SAME decide/forward path the serve loop
2108        // uses, against the freshest view — so the result and its anti-leak posture exactly match an
2109        // on-the-wire query. The id is fixed (0): a programmatic query has no concurrent-demux need,
2110        // and `response_matches_query` validates the echoed id against this same buffer.
2111        //
2112        // Normalize the name into labels: strip a single trailing dot (an FQDN's root marker — Go's
2113        // `dnsname.ToFQDN` does the same) and drop empty labels. An empty label would otherwise encode
2114        // as a lone `0x00`, identical to the QNAME root terminator, truncating the wire query and
2115        // corrupting the QTYPE/QCLASS that follow.
2116        let trimmed = query.name.strip_suffix('.').unwrap_or(&query.name);
2117        let labels: Vec<String> = trimmed
2118            .split('.')
2119            .filter(|label| !label.is_empty())
2120            .map(str::to_owned)
2121            .collect();
2122        let qtype = match query.qtype {
2123            1 => ts_dns_wire::QType::A,
2124            28 => ts_dns_wire::QType::Aaaa,
2125            12 => ts_dns_wire::QType::Ptr,
2126            other => ts_dns_wire::QType::Other(other),
2127        };
2128        // Class IN (1) — the only class the responder serves authoritatively (a non-IN class still
2129        // forwards via `forward_or_nodata`, matching the on-the-wire path).
2130        let buf = ts_dns_wire::encode_query(0, &ts_dns_wire::Name(labels), &qtype, 1);
2131
2132        let view = self.view_tx.borrow().clone();
2133
2134        let (response, resolvers_consulted) = match decide(&view, &buf) {
2135            // `decide` returns `None` only when `decode_query` rejects the buffer we just built. With
2136            // the name normalized above that can still happen for a name `encode_query` accepts but
2137            // `decode_query` rejects — a non-ASCII/IDN label (the caller must pass punycode) or a name
2138            // whose wire form exceeds 255 bytes. Surface a SERVFAIL (RCODE 2: "could not process")
2139            // rather than an empty buffer that would read back as a fabricated NoError. The serve loop
2140            // silently drops here (the on-wire client times out); a programmatic caller gets a
2141            // definite, honest error instead.
2142            None => (servfail_response(), Vec::new()),
2143            Some(Decision::Reply(resp)) => (
2144                check_response_size_and_set_tc(&buf, resp, ClientTransport::Udp),
2145                Vec::new(),
2146            ),
2147            Some(Decision::Forward {
2148                upstreams,
2149                query,
2150                servfail,
2151                recursive,
2152            }) => {
2153                let plan = forward_plan(&view, upstreams, recursive);
2154                match plan {
2155                    RecursivePlan::Udp(upstreams) => {
2156                        let resp = forward_query(
2157                            &self.channel,
2158                            &upstreams,
2159                            &query,
2160                            servfail,
2161                            ClientTransport::Udp,
2162                            view.upstream_tcp_retry(),
2163                        )
2164                        .await;
2165                        (resp, upstreams)
2166                    }
2167                    RecursivePlan::Doh(doh_addr) => {
2168                        let resp = crate::peerapi_doh::forward_doh(
2169                            &self.channel,
2170                            doh_addr,
2171                            &query,
2172                            servfail,
2173                            ClientTransport::Udp,
2174                        )
2175                        .await;
2176                        // The query egressed via the exit node's DoH endpoint, not a local UDP
2177                        // upstream — report the DoH address as the resolver consulted.
2178                        (resp, vec![doh_addr])
2179                    }
2180                }
2181            }
2182        };
2183
2184        // RCODE is the low 4 bits of the second flags byte (header byte 3).
2185        let rcode = response_rcode(&response).unwrap_or(0);
2186
2187        DnsQueryResult {
2188            response,
2189            rcode,
2190            resolvers_consulted,
2191        }
2192    }
2193}
2194
2195impl Message<Arc<ts_control::StateUpdate>> for MagicDnsActor {
2196    type Reply = ();
2197
2198    async fn handle(
2199        &mut self,
2200        update: Arc<ts_control::StateUpdate>,
2201        _ctx: &mut Context<Self, Self::Reply>,
2202    ) {
2203        // Re-read the live accept-dns cell on every rebuild (it is runtime-settable via
2204        // `Device::set_accept_dns`); `enable_ipv6` is preserved from the seed (set once at spawn).
2205        let accept_dns = self.env.accept_dns();
2206        self.view_tx.send_modify(|view| {
2207            let mut next = (**view).clone();
2208            // Most responses are deltas that carry neither the DNS config nor the self node; keep
2209            // the last ones seen rather than resetting them (Go keeps both in its netmap until a
2210            // response replaces them). Resetting dropped this node's own address, which the peerAPI
2211            // Host check needs, and its MagicDNS config on every delta.
2212            if let Some(cfg) = &update.dns_config {
2213                next.cfg = cfg.clone();
2214            }
2215            if let Some(node) = &update.node {
2216                next.self_node = Some(node.clone());
2217            }
2218            next.accept_dns = accept_dns;
2219            *view = Arc::new(next);
2220        });
2221    }
2222}
2223
2224impl Message<Arc<PeerState>> for MagicDnsActor {
2225    type Reply = ();
2226
2227    async fn handle(&mut self, state: Arc<PeerState>, _ctx: &mut Context<Self, Self::Reply>) {
2228        // Re-read the live accept-dns cell on every rebuild: `Device::set_accept_dns` triggers a
2229        // `RepublishState` that lands here, so this is the path that re-applies the gate after a
2230        // runtime toggle (covers the netstack responder AND the peerAPI DoH server sharing the view).
2231        let accept_dns = self.env.accept_dns();
2232        self.view_tx.send_modify(|view| {
2233            let mut next = (**view).clone();
2234            next.peers = Some(state.peers.clone());
2235            next.accept_dns = accept_dns;
2236            *view = Arc::new(next);
2237        });
2238    }
2239}
2240
2241impl Message<crate::route_updater::ActiveExitNode> for MagicDnsActor {
2242    type Reply = ();
2243
2244    async fn handle(
2245        &mut self,
2246        active: crate::route_updater::ActiveExitNode,
2247        _ctx: &mut Context<Self, Self::Reply>,
2248    ) {
2249        // Cache the active exit node's DoH endpoint so the serve loop delegates recursive queries
2250        // to it. `None` (no exit node, or one that can't proxy DNS) keeps recursion local. Resolving
2251        // the address here — once, from the route updater's authoritative selection — means the
2252        // serve loop never re-resolves the selector.
2253        let exit_doh = active.node.as_ref().and_then(|n| n.peerapi_doh_addr());
2254        self.view_tx.send_modify(|view| {
2255            let mut next = (**view).clone();
2256            next.exit_doh = exit_doh;
2257            *view = Arc::new(next);
2258        });
2259    }
2260}
2261
2262#[cfg(test)]
2263mod tests {
2264    use ts_control::{StableNodeId, TailnetAddress};
2265
2266    use super::*;
2267
2268    /// Test wrapper: run [`decide`] and extract the reply bytes. These tests configure no
2269    /// upstream resolvers, so an unresolved name fails closed to a `Reply` (NXDOMAIN), never a
2270    /// `Forward`; a `Forward` here is a bug and panics.
2271    fn answer(view: &DnsView, buf: &[u8]) -> Option<Vec<u8>> {
2272        match decide(view, buf)? {
2273            Decision::Reply(resp) => Some(resp),
2274            Decision::Forward { .. } => panic!("unexpected forward in authoritative-only test"),
2275        }
2276    }
2277
2278    /// Build a `Node` named `host.user.ts.net` with a known v4/v6 tailnet address.
2279    fn test_node() -> Node {
2280        Node {
2281            id: 1,
2282            stable_id: StableNodeId("n1".to_string()),
2283            hostname: "host".to_string(),
2284            user_id: 0,
2285            tailnet: Some("user.ts.net".to_string()),
2286            tags: vec![],
2287            addresses: vec![
2288                "100.64.0.1/32".parse().unwrap(),
2289                "fd7a::1/128".parse().unwrap(),
2290            ],
2291            tailnet_address: TailnetAddress {
2292                ipv4: "100.64.0.1/32".parse().unwrap(),
2293                ipv6: "fd7a::1/128".parse().unwrap(),
2294            },
2295            node_key: [0u8; 32].into(),
2296            node_key_expiry: None,
2297            expired: false,
2298            online: None,
2299            last_seen: None,
2300            key_signature: vec![],
2301            machine_key: None,
2302            disco_key: None,
2303            accepted_routes: vec![],
2304            underlay_addresses: vec![],
2305            derp_region: None,
2306            cap: Default::default(),
2307            cap_map: Default::default(),
2308            peerapi_port: None,
2309            peerapi_dns_proxy: false,
2310            is_wireguard_only: false,
2311            exit_node_dns_resolvers: vec![],
2312            peer_relay: false,
2313            ssh_host_keys: vec![],
2314            service_vips: Default::default(),
2315            unsigned_peer_api_only: false,
2316        }
2317    }
2318
2319    /// A view with MagicDNS on and a single peer in the db.
2320    fn view_with_peer() -> DnsView {
2321        let mut db = PeerDb::default();
2322        db.upsert(&test_node());
2323
2324        DnsView {
2325            cfg: DnsConfig {
2326                magic_dns: true,
2327                search_domains: vec!["user.ts.net".to_string()],
2328                ..Default::default()
2329            },
2330            peers: Some(Arc::new(db)),
2331            self_node: None,
2332            exit_doh: None,
2333            enable_ipv6: false,
2334            accept_dns: true,
2335        }
2336    }
2337
2338    /// Build a raw DNS query buffer for `labels` with the given id, qtype, qclass.
2339    fn build_query(id: u16, labels: &[&str], qtype: u16, qclass: u16) -> Vec<u8> {
2340        let mut buf: Vec<u8> = Vec::new();
2341        buf.extend_from_slice(&id.to_be_bytes());
2342        buf.extend_from_slice(&0u16.to_be_bytes()); // flags: QR=0 (query)
2343        buf.extend_from_slice(&1u16.to_be_bytes()); // QDCOUNT
2344        buf.extend_from_slice(&0u16.to_be_bytes()); // ANCOUNT
2345        buf.extend_from_slice(&0u16.to_be_bytes()); // NSCOUNT
2346        buf.extend_from_slice(&0u16.to_be_bytes()); // ARCOUNT
2347        for label in labels {
2348            buf.push(label.len() as u8);
2349            buf.extend_from_slice(label.as_bytes());
2350        }
2351        buf.push(0); // root label
2352        buf.extend_from_slice(&qtype.to_be_bytes());
2353        buf.extend_from_slice(&qclass.to_be_bytes());
2354        buf
2355    }
2356
2357    /// `build_query` plus an EDNS(0) OPT record in the additional section advertising `udp_size` as
2358    /// the requestor's UDP payload size (RFC 6891: root NAME, TYPE 41, CLASS = the size), in the
2359    /// only shape Go's `findOPTRecord` accepts: last record in the message, version 0, `RDLEN` 0.
2360    fn build_edns_query(
2361        id: u16,
2362        labels: &[&str],
2363        qtype: u16,
2364        qclass: u16,
2365        udp_size: u16,
2366    ) -> Vec<u8> {
2367        let mut buf = build_query(id, labels, qtype, qclass);
2368        buf[11] = 1; // ARCOUNT = 1
2369        buf.push(0); // NAME: root
2370        buf.extend_from_slice(&41u16.to_be_bytes()); // TYPE: OPT
2371        buf.extend_from_slice(&udp_size.to_be_bytes()); // CLASS: requestor's UDP payload size
2372        buf.extend_from_slice(&0u32.to_be_bytes()); // TTL: extended rcode + flags
2373        buf.extend_from_slice(&0u16.to_be_bytes()); // RDLENGTH: no options
2374        buf
2375    }
2376
2377    /// Like [`build_edns_query`] but with one EDNS option in the OPT record's RDATA, so `RDLEN` is
2378    /// non-zero — the shape a stub resolver sending a DNS cookie (option code 10) produces.
2379    fn build_edns_query_with_option(
2380        id: u16,
2381        labels: &[&str],
2382        qtype: u16,
2383        qclass: u16,
2384        udp_size: u16,
2385        option_code: u16,
2386        option_data: &[u8],
2387    ) -> Vec<u8> {
2388        let mut buf = build_edns_query(id, labels, qtype, qclass, udp_size);
2389        let rdata_len = 4 + option_data.len();
2390        let rdlength_at = buf.len() - 2;
2391        buf[rdlength_at..].copy_from_slice(&(rdata_len as u16).to_be_bytes());
2392        buf.extend_from_slice(&option_code.to_be_bytes());
2393        buf.extend_from_slice(&(option_data.len() as u16).to_be_bytes());
2394        buf.extend_from_slice(option_data);
2395        buf
2396    }
2397
2398    /// Parse a response header: returns `(id, rcode, ancount)`.
2399    fn parse_header(resp: &[u8]) -> (u16, u8, u16) {
2400        let id = u16::from_be_bytes([resp[0], resp[1]]);
2401        let flags = u16::from_be_bytes([resp[2], resp[3]]);
2402        let ancount = u16::from_be_bytes([resp[6], resp[7]]);
2403        (id, (flags & 0x000F) as u8, ancount)
2404    }
2405
2406    #[test]
2407    fn a_query_for_known_peer_answers_v4() {
2408        let view = view_with_peer();
2409        let buf = build_query(0x1234, &["host", "user", "ts", "net"], 1, 1);
2410
2411        let resp = answer(&view, &buf).expect("answers");
2412        let (id, rcode, ancount) = parse_header(&resp);
2413        assert_eq!(id, 0x1234);
2414        assert_eq!(rcode, 0, "NoError");
2415        assert_eq!(ancount, 1);
2416
2417        // The trailing RDATA of the single A record is the peer's tailnet v4 octets.
2418        let tail = &resp[resp.len() - 4..];
2419        assert_eq!(tail, &[100, 64, 0, 1]);
2420    }
2421
2422    #[test]
2423    fn aaaa_query_for_known_peer_is_nodata_when_ipv6_off() {
2424        // Gate OFF (default): an AAAA query for a known overlay peer must return NoError with an
2425        // empty answer (NODATA) — NOT the overlay v6 address, which the IPv4-only client can't
2426        // route. This is the anti-fingerprint / no-dead-connections posture.
2427        let view = view_with_peer();
2428        assert!(!view.enable_ipv6, "default gate is off");
2429        let buf = build_query(0x5, &["host", "user", "ts", "net"], 28, 1);
2430
2431        let resp = answer(&view, &buf).expect("answers");
2432        let (_, rcode, ancount) = parse_header(&resp);
2433        assert_eq!(rcode, 0, "NoError (NODATA)");
2434        assert_eq!(ancount, 0, "empty answer: no AAAA handed out with IPv6 off");
2435    }
2436
2437    #[test]
2438    fn a_query_still_resolves_when_ipv6_off() {
2439        // Gate OFF must not touch the A (v4) path: the v4 answer is byte-for-byte unchanged.
2440        let view = view_with_peer();
2441        let buf = build_query(0x6, &["host", "user", "ts", "net"], 1, 1);
2442
2443        let resp = answer(&view, &buf).expect("answers");
2444        let (_, rcode, ancount) = parse_header(&resp);
2445        assert_eq!(rcode, 0, "NoError");
2446        assert_eq!(ancount, 1);
2447        let tail = &resp[resp.len() - 4..];
2448        assert_eq!(tail, &[100, 64, 0, 1]);
2449    }
2450
2451    #[test]
2452    fn aaaa_query_for_known_peer_answers_v6_when_ipv6_on() {
2453        // Gate ON: historical behavior — answer AAAA from the overlay v6 address.
2454        let mut view = view_with_peer();
2455        view.enable_ipv6 = true;
2456        let buf = build_query(0x5, &["host", "user", "ts", "net"], 28, 1);
2457
2458        let resp = answer(&view, &buf).expect("answers");
2459        let (_, rcode, ancount) = parse_header(&resp);
2460        assert_eq!(rcode, 0, "NoError");
2461        assert_eq!(ancount, 1);
2462
2463        let expected = "fd7a::1".parse::<std::net::Ipv6Addr>().unwrap().octets();
2464        let tail = &resp[resp.len() - 16..];
2465        assert_eq!(tail, expected);
2466    }
2467
2468    #[test]
2469    fn aaaa_for_unknown_tailnet_name_is_nxdomain_not_forwarded_with_ipv6_off() {
2470        // Anti-leak, unchanged by the gate: an AAAA for a name under the tailnet suffix that has no
2471        // overlay match still fails closed to NXDOMAIN — never forwarded to a recursive upstream,
2472        // even with resolvers configured. (Gate OFF only changes the *positive* overlay match into
2473        // NODATA; a non-match still routes through `forward_or_nxdomain`.)
2474        let mut db = PeerDb::default();
2475        db.upsert(&test_node());
2476        let view = DnsView {
2477            cfg: DnsConfig {
2478                magic_dns: true,
2479                search_domains: vec!["user.ts.net".to_string()],
2480                fallback_resolvers: vec![DnsResolver {
2481                    transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
2482                    use_with_exit_node: false,
2483                }],
2484                ..Default::default()
2485            },
2486            peers: Some(Arc::new(db)),
2487            self_node: None,
2488            exit_doh: None,
2489            enable_ipv6: false,
2490            accept_dns: true,
2491        };
2492        let buf = build_query(0x5A, &["ghost", "user", "ts", "net"], 28, 1);
2493
2494        match decide(&view, &buf).expect("decides") {
2495            Decision::Reply(resp) => {
2496                let (_, rcode, _) = parse_header(&resp);
2497                assert_eq!(rcode, 3, "NxDomain: tailnet AAAA not leaked upstream");
2498            }
2499            Decision::Forward { .. } => panic!("tailnet AAAA must never be forwarded"),
2500        }
2501    }
2502
2503    #[test]
2504    fn bare_hostname_resolves() {
2505        // The name index also stores the bare hostname.
2506        let view = view_with_peer();
2507        let buf = build_query(0x7, &["host"], 1, 1);
2508
2509        let resp = answer(&view, &buf).expect("answers");
2510        let (_, rcode, ancount) = parse_header(&resp);
2511        assert_eq!(rcode, 0);
2512        assert_eq!(ancount, 1);
2513    }
2514
2515    #[test]
2516    fn unknown_off_tailnet_name_with_no_upstream_is_servfail() {
2517        // An off-tailnet name with no resolver configured cannot be forwarded. Go answers SERVFAIL
2518        // (a soft "couldn't resolve"), not NXDOMAIN — asserting non-existence of a real name we
2519        // simply have no upstream for would poison a downstream stub's negative cache. (A *tailnet*
2520        // name with no overlay match stays NXDOMAIN — see `tailnet_name_is_never_forwarded` — and a
2521        // negative split-DNS route stays NXDOMAIN — see `negative_route_is_nxdomain_not_forwarded`.)
2522        let view = view_with_peer();
2523        let buf = build_query(0x9, &["nope", "example", "com"], 1, 1);
2524
2525        let resp = answer(&view, &buf).expect("answers");
2526        let (_, rcode, ancount) = parse_header(&resp);
2527        assert_eq!(
2528            rcode, 2,
2529            "ServFail: off-tailnet name, nothing to forward to"
2530        );
2531        assert_eq!(ancount, 0);
2532    }
2533
2534    #[test]
2535    fn magic_dns_off_is_refused() {
2536        // Fail closed: with MagicDNS disabled, even a known name is refused.
2537        let mut view = view_with_peer();
2538        view.cfg.magic_dns = false;
2539        let buf = build_query(0xAB, &["host", "user", "ts", "net"], 1, 1);
2540
2541        let resp = answer(&view, &buf).expect("answers");
2542        let (_, rcode, ancount) = parse_header(&resp);
2543        assert_eq!(rcode, 5, "Refused");
2544        assert_eq!(ancount, 0);
2545    }
2546
2547    #[test]
2548    fn accept_dns_false_refuses_otherwise_answerable_query() {
2549        // The accept-dns gate (Go `CorpDNS`): with `accept_dns == false` the node ignores the
2550        // tailnet DNS config, so even a known peer name that would normally answer authoritatively is
2551        // REFUSED (the responder serves nothing) — mirroring Go applying an empty `dns.Config`.
2552        let mut view = view_with_peer();
2553        assert!(view.cfg.magic_dns, "MagicDNS itself is on");
2554        view.accept_dns = false;
2555        let buf = build_query(0xDD, &["host", "user", "ts", "net"], 1, 1);
2556
2557        let resp = answer(&view, &buf).expect("answers");
2558        let (_, rcode, ancount) = parse_header(&resp);
2559        assert_eq!(rcode, 5, "Refused: accept_dns off ⇒ serve nothing");
2560        assert_eq!(ancount, 0);
2561
2562        // Flip accept_dns back ON (the config was never destroyed, only gated): the same query now
2563        // answers authoritatively — proving the OFF→ON restore is automatic.
2564        view.accept_dns = true;
2565        let resp = answer(&view, &buf).expect("answers");
2566        let (_, rcode, ancount) = parse_header(&resp);
2567        assert_eq!(rcode, 0, "NoError: accept_dns on ⇒ the known peer answers");
2568        assert_eq!(ancount, 1);
2569        let tail = &resp[resp.len() - 4..];
2570        assert_eq!(tail, &[100, 64, 0, 1], "the peer's tailnet v4 is served");
2571    }
2572
2573    #[test]
2574    fn default_view_serves_nothing() {
2575        // The default (no dns_config seen) has magic_dns == false: fail closed.
2576        let view = DnsView::default();
2577        let buf = build_query(0x1, &["host", "user", "ts", "net"], 1, 1);
2578
2579        let resp = answer(&view, &buf).expect("answers");
2580        let (_, rcode, _) = parse_header(&resp);
2581        assert_eq!(rcode, 5, "Refused");
2582    }
2583
2584    #[test]
2585    fn unsupported_qtype_on_tailnet_name_is_nodata_not_refused() {
2586        // TXT (type 16) for a tailnet-authoritative name: the name exists but we hold no TXT, so —
2587        // like Go — return NODATA (empty NOERROR), NOT REFUSED (which would make a stub abandon the
2588        // resolver) and NOT NXDOMAIN (the name exists). The name is never forwarded (anti-leak).
2589        let view = view_with_peer();
2590        let buf = build_query(0x1, &["host", "user", "ts", "net"], 16, 1);
2591
2592        let resp = answer(&view, &buf).expect("answers");
2593        let (_, rcode, ancount) = parse_header(&resp);
2594        assert_eq!(rcode, 0, "NoError (NODATA), not Refused");
2595        assert_eq!(ancount, 0, "no answer records (NODATA)");
2596    }
2597
2598    #[test]
2599    fn unsupported_qtype_off_tailnet_forwards_or_servfails() {
2600        // A non-A/AAAA/PTR qtype for an OFF-tailnet name must be forwardable like A/AAAA — never
2601        // REFUSED. With no upstream configured in this view it soft-fails to SERVFAIL (the same
2602        // disposition an off-tailnet A query gets here), proving the qtype no longer short-circuits
2603        // to REFUSED. HTTPS/SVCB is type 65 (the browser HTTP/3 + ECH case the old REFUSED broke).
2604        let view = view_with_peer();
2605        let buf = build_query(0x1, &["example", "com"], 65, 1);
2606
2607        let resp = answer(&view, &buf).expect("answers");
2608        let (_, rcode, _) = parse_header(&resp);
2609        assert_eq!(
2610            rcode, 2,
2611            "off-tailnet, no upstream -> SERVFAIL (forwardable, not Refused)"
2612        );
2613    }
2614
2615    #[test]
2616    fn unimplemented_qtype_on_tailnet_name_is_notimp() {
2617        // NS (2), SOA (6), HINFO (13), AXFR (252) for a tailnet-authoritative name must answer NOTIMP
2618        // (rcode 4), matching Go `resolveLocal`'s `case dns.TypeNS, dns.TypeSOA, dns.TypeAXFR,
2619        // dns.TypeHINFO: return RCodeNotImplemented`. Returning NODATA (rcode 0) here was a clean
2620        // fingerprint (a `dig SOA user.ts.net` answer differs from real tailscaled). The name is
2621        // still never forwarded (anti-leak).
2622        let view = view_with_peer();
2623        for qtype in [2u16, 6, 13, 252] {
2624            let buf = build_query(0x1, &["host", "user", "ts", "net"], qtype, 1);
2625            let resp = answer(&view, &buf).expect("answers");
2626            let (_, rcode, ancount) = parse_header(&resp);
2627            assert_eq!(rcode, 4, "qtype {qtype} on a tailnet name must be NOTIMP");
2628            assert_eq!(ancount, 0, "NOTIMP carries no answer records");
2629        }
2630    }
2631
2632    #[test]
2633    fn unimplemented_qtype_off_tailnet_still_forwards_not_notimp() {
2634        // The NOTIMP disposition is ONLY for a name we are authoritative for. An NS query for an
2635        // off-tailnet name must still forward (here: SERVFAIL, no upstream) — NOT NOTIMP — exactly
2636        // like the off-tailnet HTTPS/SVCB case above. Guards the NOTIMP change against over-reach.
2637        let view = view_with_peer();
2638        let buf = build_query(0x1, &["example", "com"], 2, 1); // NS, off-tailnet
2639        let resp = answer(&view, &buf).expect("answers");
2640        let (_, rcode, _) = parse_header(&resp);
2641        assert_eq!(
2642            rcode, 2,
2643            "off-tailnet NS -> SERVFAIL (forwardable), not NOTIMP"
2644        );
2645    }
2646
2647    #[test]
2648    fn malformed_query_is_dropped() {
2649        // A response (QR bit set) is not a query; we drop it (no answer).
2650        let mut buf = build_query(0x1, &["host"], 1, 1);
2651        buf[2] = 0x80; // set QR bit
2652        assert!(answer(&view_with_peer(), &buf).is_none());
2653    }
2654
2655    #[test]
2656    fn ptr_for_known_ip_answers_fqdn() {
2657        let view = view_with_peer();
2658        // Reverse name for 100.64.0.1 => 1.0.64.100.in-addr.arpa
2659        let buf = build_query(0x33, &["1", "0", "64", "100", "in-addr", "arpa"], 12, 1);
2660
2661        let resp = answer(&view, &buf).expect("answers");
2662        let (_, rcode, ancount) = parse_header(&resp);
2663        assert_eq!(rcode, 0, "NoError");
2664        assert_eq!(ancount, 1);
2665
2666        // The PTR rdata encodes the peer's fqdn "host.user.ts.net" as length-prefixed labels.
2667        let expected = {
2668            let mut out = Vec::new();
2669            for label in ["host", "user", "ts", "net"] {
2670                out.push(label.len() as u8);
2671                out.extend_from_slice(label.as_bytes());
2672            }
2673            out.push(0);
2674            out
2675        };
2676        let tail = &resp[resp.len() - expected.len()..];
2677        assert_eq!(tail, expected.as_slice());
2678    }
2679
2680    #[test]
2681    fn ptr_for_unknown_public_ip_off_tailnet_is_servfail() {
2682        let view = view_with_peer();
2683        // 9.9.9.9 is a public IP, not a known tailnet IP and not in the CGNAT reverse zone — so its
2684        // reverse query is an ordinary off-tailnet name. With no upstream to forward it to, that is
2685        // SERVFAIL (soft), not NXDOMAIN. (A CGNAT/ip6.arpa reverse for an unmatched tailnet IP still
2686        // fails closed to NXDOMAIN as an anti-leak guard — see `ptr_for_unknown_tailnet_ip_*`.)
2687        let buf = build_query(0x34, &["9", "9", "9", "9", "in-addr", "arpa"], 12, 1);
2688
2689        let resp = answer(&view, &buf).expect("answers");
2690        let (_, rcode, _) = parse_header(&resp);
2691        assert_eq!(
2692            rcode, 2,
2693            "ServFail: off-tailnet public-IP reverse, no upstream"
2694        );
2695    }
2696
2697    #[test]
2698    fn ptr_for_unknown_tailnet_ip_is_nxdomain_not_forwarded() {
2699        // A view WITH an upstream resolver: an off-tailnet reverse query would forward, but a
2700        // reverse query for an unmatched IP in the CGNAT range (100.64.0.0/10) must fail closed to
2701        // NXDOMAIN — the probed tailnet IP must never leak upstream.
2702        let mut db = PeerDb::default();
2703        db.upsert(&test_node());
2704        let view = DnsView {
2705            cfg: DnsConfig {
2706                magic_dns: true,
2707                search_domains: vec!["user.ts.net".to_string()],
2708                fallback_resolvers: vec![DnsResolver {
2709                    transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
2710                    use_with_exit_node: false,
2711                }],
2712                ..Default::default()
2713            },
2714            peers: Some(Arc::new(db)),
2715            self_node: None,
2716            exit_doh: None,
2717            enable_ipv6: false,
2718            accept_dns: true,
2719        };
2720
2721        // 100.64.0.9 is in CGNAT range but owned by no peer => NXDOMAIN, never a Forward.
2722        let buf = build_query(0x35, &["9", "0", "64", "100", "in-addr", "arpa"], 12, 1);
2723        match decide(&view, &buf).expect("decides") {
2724            Decision::Reply(resp) => {
2725                let (_, rcode, _) = parse_header(&resp);
2726                assert_eq!(rcode, 3, "NxDomain");
2727            }
2728            Decision::Forward { .. } => {
2729                panic!("tailnet CGNAT PTR must never be forwarded upstream")
2730            }
2731        }
2732    }
2733
2734    /// Anti-leak regression for the exotic-qtype forward path: a NON-PTR query (TXT, type 16) for a
2735    /// tailnet CGNAT reverse name, with an upstream configured, must STILL fail closed to NXDOMAIN —
2736    /// never forward. The PTR arm guards this, but the `QType::Other` path routes through
2737    /// `forward_or_nodata`, which must re-apply the reverse-zone guard or the tailnet IP leaks.
2738    #[test]
2739    fn exotic_qtype_for_tailnet_cgnat_reverse_is_nxdomain_not_forwarded() {
2740        let mut db = PeerDb::default();
2741        db.upsert(&test_node());
2742        let view = DnsView {
2743            cfg: DnsConfig {
2744                magic_dns: true,
2745                search_domains: vec!["user.ts.net".to_string()],
2746                fallback_resolvers: vec![DnsResolver {
2747                    transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
2748                    use_with_exit_node: false,
2749                }],
2750                ..Default::default()
2751            },
2752            peers: Some(Arc::new(db)),
2753            self_node: None,
2754            exit_doh: None,
2755            enable_ipv6: false,
2756            accept_dns: true,
2757        };
2758
2759        // TXT (16) for a CGNAT reverse name => NXDOMAIN, never a Forward (no tailnet-IP leak).
2760        let buf = build_query(0x36, &["9", "0", "64", "100", "in-addr", "arpa"], 16, 1);
2761        match decide(&view, &buf).expect("decides") {
2762            Decision::Reply(resp) => {
2763                let (_, rcode, _) = parse_header(&resp);
2764                assert_eq!(rcode, 3, "NxDomain");
2765            }
2766            Decision::Forward { .. } => {
2767                panic!("a non-PTR query for a tailnet CGNAT reverse name must never forward")
2768            }
2769        }
2770    }
2771
2772    /// Same anti-leak guard for an `ip6.arpa` reverse name under an exotic qtype: must NXDOMAIN, not
2773    /// forward (revealing a tailnet ULA was probed).
2774    #[test]
2775    fn exotic_qtype_for_ip6_arpa_is_nxdomain_not_forwarded() {
2776        let view = view_with_routes(
2777            std::collections::BTreeMap::new(),
2778            vec![udp("9.9.9.9:53")],
2779            vec![],
2780        );
2781        // An ip6.arpa reverse name with a TXT (16) qtype must fail closed.
2782        let buf = build_query(
2783            0x37,
2784            &[
2785                "1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0",
2786                "a", "7", "d", "f", "ip6", "arpa",
2787            ],
2788            16,
2789            1,
2790        );
2791        match decide(&view, &buf).expect("decides") {
2792            Decision::Reply(resp) => {
2793                let (_, rcode, _) = parse_header(&resp);
2794                assert_eq!(rcode, 3, "NxDomain");
2795            }
2796            Decision::Forward { .. } => panic!("an ip6.arpa exotic-qtype query must never forward"),
2797        }
2798    }
2799
2800    #[test]
2801    fn is_tailnet_cgnat_classifies_range() {
2802        assert!(is_tailnet_cgnat("100.64.0.0".parse().unwrap()));
2803        assert!(is_tailnet_cgnat("100.64.0.1".parse().unwrap()));
2804        assert!(is_tailnet_cgnat("100.127.255.255".parse().unwrap()));
2805        // Outside the /10:
2806        assert!(!is_tailnet_cgnat("100.63.255.255".parse().unwrap()));
2807        assert!(!is_tailnet_cgnat("100.128.0.0".parse().unwrap()));
2808        assert!(!is_tailnet_cgnat("9.9.9.9".parse().unwrap()));
2809        // The MagicDNS resolver IP 100.100.100.100 is itself inside the /10.
2810        assert!(is_tailnet_cgnat("100.100.100.100".parse().unwrap()));
2811    }
2812
2813    #[test]
2814    fn response_matches_query_validates_id_and_qr() {
2815        // query id 0x1234, QR=0
2816        let query = build_query(0x1234, &["a", "com"], 1, 1);
2817
2818        // A well-formed response: same id, QR=1.
2819        let mut good = query.clone();
2820        good[2] |= 0x80;
2821        assert!(response_matches_query(&query, &good));
2822
2823        // Same id but QR still 0 (not a response): rejected.
2824        assert!(!response_matches_query(&query, &query));
2825
2826        // QR=1 but a different transaction id: rejected (off-path forgery).
2827        let mut wrong_id = good.clone();
2828        wrong_id[0] ^= 0xFF;
2829        assert!(!response_matches_query(&query, &wrong_id));
2830
2831        // Too-short buffers: rejected.
2832        assert!(!response_matches_query(&query, &[0u8; 2]));
2833        assert!(!response_matches_query(&[0u8; 3], &good));
2834    }
2835
2836    #[test]
2837    fn self_node_resolves_when_no_peer_match() {
2838        // With the peer db empty but a self node set, the self node answers for its own name.
2839        let view = DnsView {
2840            cfg: DnsConfig {
2841                magic_dns: true,
2842                search_domains: vec![],
2843                ..Default::default()
2844            },
2845            peers: None,
2846            self_node: Some(test_node()),
2847            exit_doh: None,
2848            enable_ipv6: false,
2849            accept_dns: true,
2850        };
2851        let buf = build_query(0x44, &["host", "user", "ts", "net"], 1, 1);
2852
2853        let resp = answer(&view, &buf).expect("answers");
2854        let (_, rcode, ancount) = parse_header(&resp);
2855        assert_eq!(rcode, 0);
2856        assert_eq!(ancount, 1);
2857        let tail = &resp[resp.len() - 4..];
2858        assert_eq!(tail, &[100, 64, 0, 1]);
2859    }
2860
2861    #[test]
2862    fn partially_qualified_name_resolves_via_search_domain() {
2863        // "host.user" is not indexed directly, but the "user.ts.net" search domain qualifies it
2864        // to "host.user.user.ts.net"... which does NOT match. The realistic case is "host" (bare,
2865        // already indexed) and "host.user.ts.net" (fqdn). Verify a name needing suffix expansion:
2866        // with search domain "ts.net" the partially-qualified "host.user" => "host.user.ts.net".
2867        let mut view = view_with_peer();
2868        view.cfg.search_domains = vec!["ts.net".to_string()];
2869        let buf = build_query(0x55, &["host", "user"], 1, 1);
2870
2871        let resp = answer(&view, &buf).expect("answers");
2872        let (_, rcode, ancount) = parse_header(&resp);
2873        assert_eq!(rcode, 0, "NoError via search-domain expansion");
2874        assert_eq!(ancount, 1);
2875        let tail = &resp[resp.len() - 4..];
2876        assert_eq!(tail, &[100, 64, 0, 1]);
2877    }
2878
2879    #[test]
2880    fn extra_record_a_answers_when_no_peer_match() {
2881        // A control-pushed static A record answers for a non-peer name, fail-closed otherwise.
2882        let mut view = view_with_peer();
2883        view.cfg.extra_records = vec![ts_control::ExtraRecord {
2884            name: "static.user.ts.net".to_string(),
2885            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2886        }];
2887        let buf = build_query(0x77, &["static", "user", "ts", "net"], 1, 1);
2888
2889        let resp = answer(&view, &buf).expect("answers");
2890        let (_, rcode, ancount) = parse_header(&resp);
2891        assert_eq!(rcode, 0, "NoError from extra record");
2892        assert_eq!(ancount, 1);
2893        let tail = &resp[resp.len() - 4..];
2894        assert_eq!(tail, &[100, 64, 0, 9]);
2895    }
2896
2897    #[test]
2898    fn extra_record_matches_query_case_insensitively() {
2899        // The query name is canonicalized (lowercased) at decode time, so a mixed-case query
2900        // matches a lowercase extra record.
2901        let mut view = view_with_peer();
2902        view.cfg.extra_records = vec![ts_control::ExtraRecord {
2903            name: "static.user.ts.net".to_string(),
2904            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2905        }];
2906        let buf = build_query(0x7A, &["Static", "User", "TS", "net"], 1, 1);
2907
2908        let resp = answer(&view, &buf).expect("answers");
2909        let (_, rcode, ancount) = parse_header(&resp);
2910        assert_eq!(rcode, 0, "NoError: case-insensitive match");
2911        assert_eq!(ancount, 1);
2912        let tail = &resp[resp.len() - 4..];
2913        assert_eq!(tail, &[100, 64, 0, 9]);
2914    }
2915
2916    #[test]
2917    fn extra_record_not_expanded_by_search_domain() {
2918        // Unlike peer names, an extra record is matched as an FQDN only: a bare query that would
2919        // need search-domain expansion to reach the record name must NOT resolve.
2920        let mut view = view_with_peer();
2921        view.cfg.extra_records = vec![ts_control::ExtraRecord {
2922            name: "static.user.ts.net".to_string(),
2923            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2924        }];
2925        // "static" would only reach "static.user.ts.net" via the "user.ts.net" search domain.
2926        let buf = build_query(0x7B, &["static"], 1, 1);
2927
2928        let resp = answer(&view, &buf).expect("answers");
2929        let (_, rcode, _) = parse_header(&resp);
2930        // Not search-expanded → treated as the bare off-tailnet name "static", which has no upstream
2931        // here, so SERVFAIL (soft). The point of the test — that the extra record is NOT reachable
2932        // via search expansion — holds regardless of the failure rcode.
2933        assert_eq!(
2934            rcode, 2,
2935            "ServFail: bare 'static' is not search-expanded to the extra record"
2936        );
2937    }
2938
2939    #[test]
2940    fn extra_record_aaaa_family_is_isolated() {
2941        // An A-only extra record must NOT answer an AAAA query for the same name (NxDomain).
2942        let mut view = view_with_peer();
2943        view.cfg.extra_records = vec![ts_control::ExtraRecord {
2944            name: "v4only.user.ts.net".to_string(),
2945            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2946        }];
2947        let buf = build_query(0x78, &["v4only", "user", "ts", "net"], 28, 1);
2948
2949        let resp = answer(&view, &buf).expect("answers");
2950        let (_, rcode, _) = parse_header(&resp);
2951        assert_eq!(rcode, 3, "NxDomain: A record does not satisfy AAAA");
2952    }
2953
2954    #[test]
2955    fn extra_record_ignored_when_magic_dns_off() {
2956        // Fail closed: extra records are never served while MagicDNS is disabled.
2957        let mut view = view_with_peer();
2958        view.cfg.magic_dns = false;
2959        view.cfg.extra_records = vec![ts_control::ExtraRecord {
2960            name: "static.user.ts.net".to_string(),
2961            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2962        }];
2963        let buf = build_query(0x79, &["static", "user", "ts", "net"], 1, 1);
2964
2965        let resp = answer(&view, &buf).expect("answers");
2966        let (_, rcode, _) = parse_header(&resp);
2967        assert_eq!(rcode, 5, "Refused");
2968    }
2969
2970    /// The node attribute control sets to make every subdomain of a node resolve to it (Go
2971    /// `tailcfg/nodecap`'s `NodeAttrDNSSubdomainResolve`).
2972    const DNS_SUBDOMAIN_RESOLVE: &str = "dns-subdomain-resolve";
2973
2974    /// A view holding a single peer `host.user.ts.net` that carries the `dns-subdomain-resolve`
2975    /// node attribute, so control has declared every name under it to resolve to its addresses.
2976    fn view_with_subdomain_host() -> DnsView {
2977        let mut node = test_node();
2978        node.cap_map
2979            .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
2980
2981        let mut db = PeerDb::default();
2982        db.upsert(&node);
2983
2984        let mut view = view_with_peer();
2985        view.peers = Some(Arc::new(db));
2986        view
2987    }
2988
2989    #[test]
2990    fn subdomain_of_a_subdomain_host_resolves_to_it() {
2991        // `my.host.user.ts.net` has no record of its own; its parent `host.user.ts.net` carries the
2992        // attribute, so it answers with the parent's address.
2993        let view = view_with_subdomain_host();
2994        let buf = build_query(0x90, &["my", "host", "user", "ts", "net"], 1, 1);
2995
2996        let resp = answer(&view, &buf).expect("answers");
2997        let (_, rcode, ancount) = parse_header(&resp);
2998        assert_eq!(rcode, 0, "NoError from the subdomain host");
2999        assert_eq!(ancount, 1);
3000        assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
3001    }
3002
3003    #[test]
3004    fn a_multi_label_subdomain_of_a_subdomain_host_resolves() {
3005        // The walk climbs every parent, not one level: `be.my.host` reaches `host` just as
3006        // `my.host` does. One level of parent is not what upstream implements.
3007        let view = view_with_subdomain_host();
3008        let buf = build_query(0x91, &["be", "my", "host", "user", "ts", "net"], 1, 1);
3009
3010        let resp = answer(&view, &buf).expect("answers");
3011        let (_, rcode, ancount) = parse_header(&resp);
3012        assert_eq!(rcode, 0, "NoError: the walk is not depth-limited");
3013        assert_eq!(ancount, 1);
3014        assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
3015    }
3016
3017    #[test]
3018    fn subdomain_of_a_peer_without_the_attribute_is_nxdomain() {
3019        // The attribute is what turns the walk on. Without it — the default for every node — a
3020        // subdomain of a peer name is still authoritatively absent.
3021        let view = view_with_peer();
3022        assert!(
3023            !view
3024                .node_by_name("host.user.ts.net")
3025                .expect("peer is present")
3026                .resolves_subdomains(),
3027            "the plain test peer carries no node attribute"
3028        );
3029        let buf = build_query(0x92, &["my", "host", "user", "ts", "net"], 1, 1);
3030
3031        let resp = answer(&view, &buf).expect("answers");
3032        let (_, rcode, ancount) = parse_header(&resp);
3033        assert_eq!(rcode, 3, "NxDomain: no attribute, no subdomain resolution");
3034        assert_eq!(ancount, 0);
3035    }
3036
3037    #[test]
3038    fn an_exact_match_beats_the_subdomain_host() {
3039        // The walk is the *miss* path: a name that resolves exactly — here a control-pushed extra
3040        // record — keeps its own answer, and never takes the parent's.
3041        let mut view = view_with_subdomain_host();
3042        view.cfg.extra_records = vec![ts_control::ExtraRecord {
3043            name: "my.host.user.ts.net".to_string(),
3044            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
3045        }];
3046        let buf = build_query(0x93, &["my", "host", "user", "ts", "net"], 1, 1);
3047
3048        let resp = answer(&view, &buf).expect("answers");
3049        let (_, rcode, ancount) = parse_header(&resp);
3050        assert_eq!(rcode, 0, "NoError");
3051        assert_eq!(ancount, 1);
3052        assert_eq!(
3053            &resp[resp.len() - 4..],
3054            &[100, 64, 0, 9],
3055            "the exact record answers, not the subdomain host's address"
3056        );
3057    }
3058
3059    #[test]
3060    fn the_subdomain_walk_stops_at_the_tailnet_zone() {
3061        // A node whose own FQDN *is* the search domain must not make the whole zone a wildcard:
3062        // the walk stops at the zone apex rather than climbing into names we do not serve.
3063        let mut zone_node = test_node();
3064        zone_node.hostname = "user".to_string();
3065        zone_node.tailnet = Some("ts.net".to_string());
3066        zone_node
3067            .cap_map
3068            .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
3069        assert_eq!(zone_node.fqdn(false), "user.ts.net", "the zone apex itself");
3070
3071        let mut db = PeerDb::default();
3072        db.upsert(&zone_node);
3073        let mut view = view_with_peer();
3074        view.peers = Some(Arc::new(db));
3075
3076        for labels in [
3077            ["nothing", "user", "ts", "net"].as_slice(),
3078            ["deeper", "nothing", "user", "ts", "net"].as_slice(),
3079        ] {
3080            let buf = build_query(0x94, labels, 1, 1);
3081            let resp = answer(&view, &buf).expect("answers");
3082            let (_, rcode, ancount) = parse_header(&resp);
3083            assert_eq!(rcode, 3, "NxDomain: the walk stopped at {:?}", labels);
3084            assert_eq!(ancount, 0);
3085        }
3086    }
3087
3088    #[test]
3089    fn the_subdomain_walk_does_not_search_expand_a_bare_label() {
3090        // The peer-name index also holds bare hostnames, so a peer named after a public suffix must
3091        // not swallow every name under it: only a fully-qualified parent is a walk candidate. Go
3092        // cannot do this at all — its resolver does no search-list expansion.
3093        let mut suffix_node = test_node();
3094        suffix_node.hostname = "com".to_string();
3095        suffix_node
3096            .cap_map
3097            .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
3098
3099        let mut db = PeerDb::default();
3100        db.upsert(&suffix_node);
3101        let mut view = view_with_peer();
3102        view.peers = Some(Arc::new(db));
3103
3104        let buf = build_query(0x95, &["www", "example", "com"], 1, 1);
3105        let resp = answer(&view, &buf).expect("answers");
3106        let (_, rcode, ancount) = parse_header(&resp);
3107        assert_eq!(
3108            rcode, 2,
3109            "ServFail: an off-tailnet name with no upstream, NOT the peer named 'com'"
3110        );
3111        assert_eq!(ancount, 0, "no answer manufactured from a bare hostname");
3112
3113        // The qualified form of the same peer still resolves its subdomains: the bound rejects the
3114        // bare label, not the subdomain host.
3115        let buf = build_query(0x96, &["www", "com", "user", "ts", "net"], 1, 1);
3116        let resp = answer(&view, &buf).expect("answers");
3117        let (_, rcode, ancount) = parse_header(&resp);
3118        assert_eq!(rcode, 0, "NoError from com.user.ts.net");
3119        assert_eq!(ancount, 1);
3120        assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
3121    }
3122
3123    #[test]
3124    fn aaaa_for_a_subdomain_host_follows_the_ipv6_gate() {
3125        // The subdomain answer is the parent node's address, so it takes the same AAAA gate an
3126        // exact peer match does: NODATA with IPv6 off, the overlay v6 with it on.
3127        let mut view = view_with_subdomain_host();
3128        let buf = build_query(0x97, &["my", "host", "user", "ts", "net"], 28, 1);
3129
3130        let resp = answer(&view, &buf).expect("answers");
3131        let (_, rcode, ancount) = parse_header(&resp);
3132        assert_eq!(rcode, 0, "NoError (NODATA) with the gate off");
3133        assert_eq!(ancount, 0);
3134
3135        view.enable_ipv6 = true;
3136        let resp = answer(&view, &buf).expect("answers");
3137        let (_, rcode, ancount) = parse_header(&resp);
3138        assert_eq!(rcode, 0, "NoError");
3139        assert_eq!(ancount, 1);
3140        let expected = "fd7a::1".parse::<std::net::Ipv6Addr>().unwrap().octets();
3141        assert_eq!(&resp[resp.len() - 16..], expected);
3142    }
3143
3144    #[test]
3145    fn a_subdomain_of_the_self_node_resolves_when_it_has_the_attribute() {
3146        // The walk runs over the same name lookup the exact match uses, so the self node is a
3147        // subdomain host too when control sets the attribute on it.
3148        let mut self_node = test_node();
3149        self_node.hostname = "me".to_string();
3150        self_node
3151            .cap_map
3152            .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
3153
3154        let mut view = view_with_peer();
3155        view.peers = None;
3156        view.self_node = Some(self_node);
3157
3158        let buf = build_query(0x98, &["a", "b", "me", "user", "ts", "net"], 1, 1);
3159        let resp = answer(&view, &buf).expect("answers");
3160        let (_, rcode, ancount) = parse_header(&resp);
3161        assert_eq!(rcode, 0, "NoError from the self node");
3162        assert_eq!(ancount, 1);
3163        assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
3164    }
3165
3166    #[test]
3167    fn non_in_class_on_tailnet_name_is_nodata_not_answered_as_in() {
3168        // A CHAOS-class (3) query for a tailnet name must NOT be answered as IN (no overlay A), and
3169        // must NOT be REFUSED (Go does no class check on the local path). It's an unsupported
3170        // authoritative class -> NODATA (empty NOERROR), and never forwarded (tailnet name).
3171        let view = view_with_peer();
3172        let buf = build_query(0x66, &["host", "user", "ts", "net"], 1, 3);
3173
3174        let resp = answer(&view, &buf).expect("answers");
3175        let (_, rcode, ancount) = parse_header(&resp);
3176        assert_eq!(
3177            rcode, 0,
3178            "NoError (NODATA), not Refused and not an IN answer"
3179        );
3180        assert_eq!(
3181            ancount, 0,
3182            "must not hand out the overlay A for a non-IN class"
3183        );
3184    }
3185
3186    #[test]
3187    fn non_in_class_off_tailnet_forwards_or_servfails() {
3188        // A non-IN class for an OFF-tailnet name is forwardable (Go forwards it), never REFUSED.
3189        // No upstream here -> SERVFAIL, proving the class gate no longer short-circuits to Refused.
3190        let view = view_with_peer();
3191        let buf = build_query(0x66, &["example", "com"], 1, 3);
3192
3193        let resp = answer(&view, &buf).expect("answers");
3194        let (_, rcode, _) = parse_header(&resp);
3195        assert_eq!(
3196            rcode, 2,
3197            "off-tailnet non-IN class, no upstream -> SERVFAIL, not Refused"
3198        );
3199    }
3200
3201    /// A view with MagicDNS on, the `user.ts.net` search domain, and the given split-DNS routes
3202    /// + global resolvers.
3203    fn view_with_routes(
3204        routes: std::collections::BTreeMap<String, Vec<DnsResolver>>,
3205        resolvers: Vec<DnsResolver>,
3206        fallback: Vec<DnsResolver>,
3207    ) -> DnsView {
3208        DnsView {
3209            cfg: DnsConfig {
3210                magic_dns: true,
3211                search_domains: vec!["user.ts.net".to_string()],
3212                routes,
3213                resolvers,
3214                fallback_resolvers: fallback,
3215                ..Default::default()
3216            },
3217            peers: None,
3218            self_node: None,
3219            exit_doh: None,
3220            enable_ipv6: false,
3221            accept_dns: true,
3222        }
3223    }
3224
3225    fn udp(addr: &str) -> DnsResolver {
3226        DnsResolver {
3227            transport: ts_control::ResolverTransport::Udp(addr.parse().unwrap()),
3228            use_with_exit_node: false,
3229        }
3230    }
3231
3232    #[test]
3233    fn split_dns_route_forwards_to_matching_upstream() {
3234        let mut routes = std::collections::BTreeMap::new();
3235        routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
3236        let view = view_with_routes(routes, vec![], vec![]);
3237        let buf = build_query(0x100, &["api", "corp", "example"], 1, 1);
3238
3239        match decide(&view, &buf).expect("decides") {
3240            Decision::Forward { upstreams, .. } => {
3241                assert_eq!(upstreams, vec!["10.0.0.53:53".parse().unwrap()]);
3242            }
3243            Decision::Reply(_) => panic!("expected forward to the split-DNS upstream"),
3244        }
3245    }
3246
3247    #[test]
3248    fn exotic_qtype_off_tailnet_forwards_to_upstream() {
3249        // The core of the fix: an HTTPS/SVCB (type 65) query for an off-tailnet name with a matching
3250        // route must FORWARD to the upstream (verbatim), exactly like an A query would — not REFUSE
3251        // and not NXDOMAIN. This is the browser HTTP/3 + ECH case the old blanket-REFUSE broke.
3252        let mut routes = std::collections::BTreeMap::new();
3253        routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
3254        let view = view_with_routes(routes, vec![], vec![]);
3255        let buf = build_query(0x102, &["api", "corp", "example"], 65, 1);
3256
3257        match decide(&view, &buf).expect("decides") {
3258            Decision::Forward {
3259                upstreams, query, ..
3260            } => {
3261                assert_eq!(upstreams, vec!["10.0.0.53:53".parse().unwrap()]);
3262                assert_eq!(query, buf, "the exotic-qtype query is forwarded verbatim");
3263            }
3264            Decision::Reply(_) => {
3265                panic!("an off-tailnet HTTPS-record query must forward, not reply")
3266            }
3267        }
3268    }
3269
3270    #[test]
3271    fn non_in_class_off_tailnet_forwards_to_upstream() {
3272        // A non-IN class for an off-tailnet routed name forwards too (Go does no class check on the
3273        // local path). Proves the class gate no longer short-circuits to REFUSED before routing.
3274        let mut routes = std::collections::BTreeMap::new();
3275        routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
3276        let view = view_with_routes(routes, vec![], vec![]);
3277        let buf = build_query(0x103, &["api", "corp", "example"], 1, 3);
3278
3279        match decide(&view, &buf).expect("decides") {
3280            Decision::Forward { upstreams, .. } => {
3281                assert_eq!(upstreams, vec!["10.0.0.53:53".parse().unwrap()]);
3282            }
3283            Decision::Reply(_) => {
3284                panic!("an off-tailnet non-IN-class query must forward, not reply")
3285            }
3286        }
3287    }
3288
3289    /// The local responder bounds concurrent in-flight forwards: `serve` acquires one
3290    /// `MAX_INFLIGHT_FORWARDS` permit per spawned forward task and drops the query fail-closed when
3291    /// the pool is exhausted (a client spraying forwardable names can't open unbounded overlay
3292    /// sockets). This pins the gating semantics `serve` relies on — drained pool refuses a new
3293    /// permit; releasing one restores capacity — and the cap constant itself. (The async `serve`
3294    /// loop has no netstack-free test seam, so the semaphore behavior is exercised directly here, the
3295    /// same `Arc<Semaphore>::try_acquire_owned` the loop uses.)
3296    #[test]
3297    fn forward_inflight_cap_fails_closed_when_saturated() {
3298        use std::sync::Arc;
3299
3300        use tokio::sync::Semaphore;
3301
3302        let inflight = Arc::new(Semaphore::new(MAX_INFLIGHT_FORWARDS));
3303
3304        // Drain every permit (one per concurrently in-flight forward).
3305        let mut held = Vec::with_capacity(MAX_INFLIGHT_FORWARDS);
3306        for _ in 0..MAX_INFLIGHT_FORWARDS {
3307            held.push(
3308                inflight
3309                    .clone()
3310                    .try_acquire_owned()
3311                    .expect("permits available below the cap"),
3312            );
3313        }
3314
3315        // At the cap, the next forward is refused — `serve` would drop the query, not spawn.
3316        assert!(
3317            inflight.clone().try_acquire_owned().is_err(),
3318            "a saturated forward pool must refuse a new permit (fail closed)"
3319        );
3320
3321        // Completing an in-flight forward releases its permit and restores capacity.
3322        drop(held.pop());
3323        assert!(
3324            inflight.clone().try_acquire_owned().is_ok(),
3325            "releasing a permit must let the next forward proceed"
3326        );
3327    }
3328
3329    /// A permit moved into a spawned forward task (the `let _permit = permit;` shape `serve` uses)
3330    /// must stay held for the *whole* task body — across the `.await` on the upstream — and release
3331    /// only when the task completes. This guards the regression the saturation test above can't see:
3332    /// "tidying" `let _permit = permit;` to `let _ = permit;` would drop the permit immediately,
3333    /// re-opening unbounded concurrency while leaving the synchronous drain/restore test green. Here a
3334    /// 1-permit pool is consumed by a task that holds it across a yield; the pool must read empty
3335    /// while the task runs and refill once it finishes.
3336    #[tokio::test]
3337    async fn forward_permit_is_held_for_the_task_lifetime_not_dropped_early() {
3338        use std::sync::Arc;
3339
3340        use tokio::sync::Semaphore;
3341
3342        let inflight = Arc::new(Semaphore::new(1));
3343        let permit = inflight
3344            .clone()
3345            .try_acquire_owned()
3346            .expect("the sole permit is available");
3347
3348        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
3349        let (release_tx, release_rx) = tokio::sync::oneshot::channel();
3350        let task = tokio::spawn(async move {
3351            // Same shape as `serve`'s spawned forward: the permit is a named binding moved into the
3352            // task, so it lives until the body ends — not dropped at the `let`.
3353            let _permit = permit;
3354            started_tx.send(()).unwrap();
3355            // Stand in for the `.await` on the upstream forward.
3356            release_rx.await.unwrap();
3357        });
3358
3359        started_rx.await.unwrap();
3360        // While the task runs, the permit it moved in is still held — the pool is empty.
3361        assert!(
3362            inflight.clone().try_acquire_owned().is_err(),
3363            "a permit moved into a running task must stay held across its await"
3364        );
3365
3366        // Let the task finish; its permit drops with the body and capacity returns.
3367        release_tx.send(()).unwrap();
3368        task.await.unwrap();
3369        assert!(
3370            inflight.clone().try_acquire_owned().is_ok(),
3371            "the permit must be released once the task body completes"
3372        );
3373    }
3374
3375    /// The address of the `n`th fake upstream resolver (RFC 5737 documentation range).
3376    fn upstream_addr(n: u8) -> SocketAddr {
3377        SocketAddr::from((Ipv4Addr::new(198, 51, 100, n), 53))
3378    }
3379
3380    /// Turn `query` into an upstream response: echo the header and question back with `QR` set and
3381    /// `rcode` in the header's low nibble, then append `tail` verbatim, counted as `ancount` answer
3382    /// records. The forwarder relays bytes and never parses past the question, so an opaque tail is
3383    /// what tells two upstreams' responses apart — and stands in for the RFC 8914 extended DNS error
3384    /// a real resolver puts in its own SERVFAIL/REFUSED.
3385    fn upstream_response(query: &[u8], rcode: u8, ancount: u16, tail: &[u8]) -> Vec<u8> {
3386        let mut resp = query.to_vec();
3387        resp[2] |= 0x80; // QR = 1 (this is a response)
3388        resp[3] = (resp[3] & 0xF0) | rcode;
3389        resp[6..8].copy_from_slice(&ancount.to_be_bytes());
3390        resp.extend_from_slice(tail);
3391        resp
3392    }
3393
3394    /// One scripted upstream for [`run_forward_walk`]: the upstream's address, and the
3395    /// `(source address, datagram)` it hands back — `None` when nothing came back at all.
3396    type ScriptedUpstream = (SocketAddr, Option<(SocketAddr, Vec<u8>)>);
3397
3398    /// Run the real [`forward_walk`] over a scripted set of upstreams: each entry is
3399    /// `(upstream, answer)`, where `answer` is the `(source address, datagram)` that upstream hands
3400    /// back (`None` = nothing came back — a timeout, a bind/send/recv failure). Returns the bytes
3401    /// the client would get **and** the upstreams the walk actually asked, so a test can tell "the
3402    /// second upstream answered" apart from "the walk stopped at the first".
3403    ///
3404    /// The script stands in for [`ask_upstream`]'s overlay socket exchange only; every decision
3405    /// under test — the source/transaction-id check, the REFUSED/SERVFAIL soft-error rules, which
3406    /// response is relayed — is made by the production code being called. Every scripted answer
3407    /// arrives over the UDP hop, which is the hop the walk's own rules are about, and is read by the
3408    /// production race with the TCP hop switched off ([`TcpRetry::Disabled`]), so a scripted soft
3409    /// error reaches the walk as the failure [`ask_upstream`] would hand it. The TCP hop is
3410    /// [`race_udp_and_tcp`]'s business and is tested there.
3411    async fn run_forward_walk(
3412        script: &[ScriptedUpstream],
3413        query: &[u8],
3414        fallback: Vec<u8>,
3415    ) -> (Vec<u8>, Vec<SocketAddr>) {
3416        let upstreams: Vec<SocketAddr> = script.iter().map(|(upstream, _)| *upstream).collect();
3417        let asked = std::cell::RefCell::new(Vec::new());
3418
3419        let response = forward_walk(
3420            &upstreams,
3421            query,
3422            fallback,
3423            ClientTransport::Udp,
3424            |upstream| {
3425                asked.borrow_mut().push(upstream);
3426                let datagram = script
3427                    .iter()
3428                    .find(|(scripted, _)| *scripted == upstream)
3429                    .and_then(|(_, datagram)| datagram.clone());
3430                race_udp_and_tcp(
3431                    upstream,
3432                    query,
3433                    ClientTransport::Udp,
3434                    TcpRetry::Disabled,
3435                    move || std::future::ready(datagram),
3436                    || std::future::ready(None),
3437                )
3438            },
3439        )
3440        .await;
3441
3442        (response, asked.into_inner())
3443    }
3444
3445    /// A first upstream answering REFUSED must NOT end the forward. A broken or misconfigured
3446    /// resolver refuses instantly and would otherwise beat a healthy one that is still working,
3447    /// handing the stub resolver a refusal as though it were the answer — complete DNS failure
3448    /// wherever a split-DNS route or a fallback list names more than one resolver.
3449    #[tokio::test]
3450    async fn refused_first_upstream_does_not_end_the_walk() {
3451        let query = build_query(0x201, &["api", "example", "com"], 1, 1);
3452        let (first, second) = (upstream_addr(1), upstream_addr(2));
3453        let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"refused");
3454        let answer = upstream_response(&query, 0, 1, b"the real answer");
3455        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3456
3457        let (got, asked) = run_forward_walk(
3458            &[
3459                (first, Some((first, refusal))),
3460                (second, Some((second, answer.clone()))),
3461            ],
3462            &query,
3463            fallback,
3464        )
3465        .await;
3466
3467        assert_eq!(
3468            asked,
3469            vec![first, second],
3470            "a REFUSED from the first upstream must not stop the walk"
3471        );
3472        assert_eq!(
3473            got, answer,
3474            "the healthy second upstream's answer is what reaches the client"
3475        );
3476    }
3477
3478    /// SERVFAIL is soft in the same way: the walk goes on and the healthy upstream's answer wins.
3479    #[tokio::test]
3480    async fn servfail_first_upstream_does_not_end_the_walk() {
3481        let query = build_query(0x202, &["api", "example", "com"], 1, 1);
3482        let (first, second) = (upstream_addr(1), upstream_addr(2));
3483        let soft_fail = upstream_response(&query, RCODE_SERVFAIL, 0, b"servfail");
3484        let answer = upstream_response(&query, 0, 1, b"the real answer");
3485        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3486
3487        let (got, asked) = run_forward_walk(
3488            &[
3489                (first, Some((first, soft_fail))),
3490                (second, Some((second, answer.clone()))),
3491            ],
3492            &query,
3493            fallback,
3494        )
3495        .await;
3496
3497        assert_eq!(asked, vec![first, second], "SERVFAIL is a soft error too");
3498        assert_eq!(
3499            got, answer,
3500            "the second upstream's answer reaches the client"
3501        );
3502    }
3503
3504    /// An RCODE that is *not* soft is an answer: NXDOMAIN ends the walk where it is found, and the
3505    /// upstreams after it are never asked. (Making everything soft would turn a legitimate
3506    /// "no such name" into a needless extra round trip — and, with a second refusing upstream, into
3507    /// a different answer entirely.)
3508    #[tokio::test]
3509    async fn nxdomain_ends_the_walk_at_the_first_upstream() {
3510        let query = build_query(0x203, &["nope", "example", "com"], 1, 1);
3511        let (first, second) = (upstream_addr(1), upstream_addr(2));
3512        let nxdomain = upstream_response(&query, 3, 0, b"no such name");
3513        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3514
3515        let (got, asked) = run_forward_walk(
3516            &[
3517                (first, Some((first, nxdomain.clone()))),
3518                (
3519                    second,
3520                    Some((second, upstream_response(&query, 0, 1, b"late"))),
3521                ),
3522            ],
3523            &query,
3524            fallback,
3525        )
3526        .await;
3527
3528        assert_eq!(asked, vec![first], "NXDOMAIN is an answer: stop asking");
3529        assert_eq!(got, nxdomain, "and it is what the client gets");
3530    }
3531
3532    /// When every upstream refuses, the client gets the FIRST refusal, byte for byte — not the
3533    /// caller's synthesized SERVFAIL. The upstream's own bytes can carry an RFC 8914 extended DNS
3534    /// error explaining the refusal; a locally built packet throws that away.
3535    #[tokio::test]
3536    async fn every_upstream_refusing_returns_the_first_refusal_verbatim() {
3537        let query = build_query(0x204, &["api", "example", "com"], 1, 1);
3538        let (first, second) = (upstream_addr(1), upstream_addr(2));
3539        let first_refusal =
3540            upstream_response(&query, RCODE_REFUSED, 0, b"first refusal + extended error");
3541        let second_refusal = upstream_response(&query, RCODE_REFUSED, 0, b"second refusal");
3542        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3543
3544        let (got, asked) = run_forward_walk(
3545            &[
3546                (first, Some((first, first_refusal.clone()))),
3547                (second, Some((second, second_refusal.clone()))),
3548            ],
3549            &query,
3550            fallback.clone(),
3551        )
3552        .await;
3553
3554        assert_eq!(
3555            asked,
3556            vec![first, second],
3557            "every upstream is given its turn"
3558        );
3559        assert_eq!(
3560            got, first_refusal,
3561            "an all-refused forward relays the first upstream's own REFUSED bytes"
3562        );
3563        assert_ne!(
3564            got, fallback,
3565            "the synthesized SERVFAIL must not replace an upstream's own response"
3566        );
3567        assert_ne!(got, second_refusal, "the FIRST refusal is the one kept");
3568    }
3569
3570    /// The first *soft* response is the one kept whichever code it carried: a SERVFAIL followed by a
3571    /// REFUSED relays the upstream's own SERVFAIL, extended error and all, rather than the
3572    /// synthesized one the caller supplied.
3573    #[tokio::test]
3574    async fn every_upstream_soft_failing_returns_the_upstream_servfail_not_the_fallback() {
3575        let query = build_query(0x205, &["api", "example", "com"], 1, 1);
3576        let (first, second) = (upstream_addr(1), upstream_addr(2));
3577        let upstream_servfail = upstream_response(
3578            &query,
3579            RCODE_SERVFAIL,
3580            0,
3581            b"upstream servfail + extended error",
3582        );
3583        let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"second refusal");
3584        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3585
3586        let (got, _asked) = run_forward_walk(
3587            &[
3588                (first, Some((first, upstream_servfail.clone()))),
3589                (second, Some((second, refusal))),
3590            ],
3591            &query,
3592            fallback.clone(),
3593        )
3594        .await;
3595
3596        assert_eq!(
3597            got, upstream_servfail,
3598            "the upstream's own SERVFAIL is relayed verbatim, keeping any extended DNS error"
3599        );
3600        assert_ne!(got, fallback, "not the locally synthesized SERVFAIL");
3601    }
3602
3603    /// A first REFUSED is relayed only when every failure was a refusal. When a later upstream
3604    /// answered SERVFAIL, Go's tally has seen a non-refusal and the first failure is not a SERVFAIL,
3605    /// so the client gets the synthesized SERVFAIL — neither the refusal nor the later SERVFAIL.
3606    #[tokio::test]
3607    async fn refusal_then_servfail_returns_the_fallback_not_the_refusal() {
3608        let query = build_query(0x20a, &["api", "example", "com"], 1, 1);
3609        let (first, second) = (upstream_addr(1), upstream_addr(2));
3610        let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"first refusal");
3611        let servfail = upstream_response(&query, RCODE_SERVFAIL, 0, b"second servfail");
3612        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3613
3614        let (got, asked) = run_forward_walk(
3615            &[
3616                (first, Some((first, refusal.clone()))),
3617                (second, Some((second, servfail.clone()))),
3618            ],
3619            &query,
3620            fallback.clone(),
3621        )
3622        .await;
3623
3624        assert_eq!(asked, vec![first, second]);
3625        assert_eq!(
3626            got, fallback,
3627            "a SERVFAIL anywhere means the first REFUSED is not what the client is told"
3628        );
3629        assert_ne!(got, refusal);
3630        assert_ne!(got, servfail, "only a FIRST failure's SERVFAIL is relayed");
3631    }
3632
3633    /// The same when the later upstream said nothing at all: a timeout is a non-refusal failure, so
3634    /// the first REFUSED gives way to the synthesized SERVFAIL.
3635    #[tokio::test]
3636    async fn refusal_then_silent_upstream_returns_the_fallback_not_the_refusal() {
3637        let query = build_query(0x20b, &["api", "example", "com"], 1, 1);
3638        let (first, second) = (upstream_addr(1), upstream_addr(2));
3639        let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"first refusal");
3640        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3641
3642        let (got, asked) = run_forward_walk(
3643            &[(first, Some((first, refusal.clone()))), (second, None)],
3644            &query,
3645            fallback.clone(),
3646        )
3647        .await;
3648
3649        assert_eq!(asked, vec![first, second]);
3650        assert_eq!(got, fallback, "a silent upstream is not a refusal");
3651    }
3652
3653    /// When the first failure is a silence, a later upstream's SERVFAIL is not relayed either: Go
3654    /// relays an upstream's bytes only when the FIRST error carries them.
3655    #[tokio::test]
3656    async fn silent_upstream_then_servfail_returns_the_fallback() {
3657        let query = build_query(0x20c, &["api", "example", "com"], 1, 1);
3658        let (first, second) = (upstream_addr(1), upstream_addr(2));
3659        let servfail = upstream_response(&query, RCODE_SERVFAIL, 0, b"second servfail");
3660        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3661
3662        let (got, _asked) = run_forward_walk(
3663            &[(first, None), (second, Some((second, servfail.clone())))],
3664            &query,
3665            fallback.clone(),
3666        )
3667        .await;
3668
3669        assert_eq!(got, fallback);
3670        assert_ne!(got, servfail);
3671    }
3672
3673    /// A lone upstream that refuses still has its refusal relayed: with nothing else to wait for,
3674    /// treating REFUSED as soft changes nothing about what the client is told.
3675    #[tokio::test]
3676    async fn lone_refusing_upstream_still_has_its_refusal_relayed() {
3677        let query = build_query(0x206, &["api", "example", "com"], 1, 1);
3678        let only = upstream_addr(1);
3679        let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"refused");
3680        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3681
3682        let (got, asked) =
3683            run_forward_walk(&[(only, Some((only, refusal.clone())))], &query, fallback).await;
3684
3685        assert_eq!(asked, vec![only]);
3686        assert_eq!(
3687            got, refusal,
3688            "a single upstream's REFUSED is the client's answer"
3689        );
3690    }
3691
3692    /// The anti-poisoning check still runs BEFORE any of the soft-error handling: a datagram whose
3693    /// transaction id is not the one we asked with is discarded outright and never remembered as
3694    /// "the first REFUSED", so an off-path injector cannot plant the response an all-refused forward
3695    /// ends up relaying.
3696    #[tokio::test]
3697    async fn wrong_transaction_id_response_is_discarded_not_remembered_as_a_soft_error() {
3698        let query = build_query(0x207, &["api", "example", "com"], 1, 1);
3699        let only = upstream_addr(1);
3700        let mut poisoned = upstream_response(&query, RCODE_REFUSED, 0, b"injected");
3701        poisoned[0] ^= 0xFF; // a transaction id we never asked with
3702        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3703
3704        let (got, _asked) = run_forward_walk(
3705            &[(only, Some((only, poisoned.clone())))],
3706            &query,
3707            fallback.clone(),
3708        )
3709        .await;
3710
3711        assert_ne!(
3712            got, poisoned,
3713            "a mismatched transaction id must never be relayed"
3714        );
3715        assert_eq!(
3716            got, fallback,
3717            "with the datagram discarded nothing answered, so the synthesized fallback stands"
3718        );
3719    }
3720
3721    /// The same for the source check: a well-formed REFUSED that echoes the question and the
3722    /// transaction id but arrives from an address we did not query is discarded before it can become
3723    /// the forward's remembered soft error.
3724    #[tokio::test]
3725    async fn off_path_source_response_is_discarded_not_remembered_as_a_soft_error() {
3726        let query = build_query(0x208, &["api", "example", "com"], 1, 1);
3727        let (only, off_path) = (upstream_addr(1), upstream_addr(9));
3728        let poisoned = upstream_response(&query, RCODE_REFUSED, 0, b"injected");
3729        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3730
3731        let (got, _asked) = run_forward_walk(
3732            &[(only, Some((off_path, poisoned.clone())))],
3733            &query,
3734            fallback.clone(),
3735        )
3736        .await;
3737
3738        assert_ne!(
3739            got, poisoned,
3740            "a datagram from an unqueried source must never be relayed"
3741        );
3742        assert_eq!(
3743            got, fallback,
3744            "with the datagram discarded nothing answered, so the synthesized fallback stands"
3745        );
3746    }
3747
3748    /// An upstream that says nothing at all (timeout, bind/send/recv failure) is simply skipped, and
3749    /// the next upstream's answer is what the client gets.
3750    #[tokio::test]
3751    async fn silent_upstream_is_skipped_for_the_next_one() {
3752        let query = build_query(0x209, &["api", "example", "com"], 1, 1);
3753        let (first, second) = (upstream_addr(1), upstream_addr(2));
3754        let answer = upstream_response(&query, 0, 1, b"the real answer");
3755        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3756
3757        let (got, asked) = run_forward_walk(
3758            &[(first, None), (second, Some((second, answer.clone())))],
3759            &query,
3760            fallback,
3761        )
3762        .await;
3763
3764        assert_eq!(asked, vec![first, second]);
3765        assert_eq!(got, answer);
3766    }
3767
3768    /// A REFUSED is relayed only when EVERY upstream refused. Once a later upstream SERVFAILs, the
3769    /// forward did not fail by policy alone, and Go's forwarder answers SERVFAIL: its first error is
3770    /// not a SERVFAIL response, so it synthesizes one rather than relaying the refusal.
3771    #[tokio::test]
3772    async fn refusal_then_servfail_is_the_fallback_not_the_refusal() {
3773        let query = build_query(0x20A, &["api", "example", "com"], 1, 1);
3774        let (first, second) = (upstream_addr(1), upstream_addr(2));
3775        let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"refused");
3776        let servfail = upstream_response(&query, RCODE_SERVFAIL, 0, b"second servfail");
3777        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3778
3779        let (got, asked) = run_forward_walk(
3780            &[
3781                (first, Some((first, refusal.clone()))),
3782                (second, Some((second, servfail.clone()))),
3783            ],
3784            &query,
3785            fallback.clone(),
3786        )
3787        .await;
3788
3789        assert_eq!(asked, vec![first, second]);
3790        assert_eq!(
3791            got, fallback,
3792            "a refusal mixed with a SERVFAIL is a SERVFAIL"
3793        );
3794        assert_ne!(got, refusal, "the first refusal must not be relayed");
3795        assert_ne!(
3796            got, servfail,
3797            "nor a later SERVFAIL that was not the first failure"
3798        );
3799    }
3800
3801    /// The same when the later upstream never answers: a transport failure is not a refusal, so the
3802    /// forward is no longer "every upstream refused" and the client gets the SERVFAIL fallback.
3803    #[tokio::test]
3804    async fn refusal_then_silence_is_the_fallback_not_the_refusal() {
3805        let query = build_query(0x20B, &["api", "example", "com"], 1, 1);
3806        let (first, second) = (upstream_addr(1), upstream_addr(2));
3807        let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"refused");
3808        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3809
3810        let (got, asked) = run_forward_walk(
3811            &[(first, Some((first, refusal.clone()))), (second, None)],
3812            &query,
3813            fallback.clone(),
3814        )
3815        .await;
3816
3817        assert_eq!(asked, vec![first, second]);
3818        assert_eq!(got, fallback, "a refusal then no answer is a SERVFAIL");
3819    }
3820
3821    /// A datagram the anti-poisoning check discards counts as a non-refusal failure too (Go's
3822    /// transaction-id mismatch is an error that is not `errRefused`), so it also stops an earlier
3823    /// refusal from being relayed.
3824    #[tokio::test]
3825    async fn refusal_then_mismatched_response_is_the_fallback_not_the_refusal() {
3826        let query = build_query(0x20C, &["api", "example", "com"], 1, 1);
3827        let (first, second) = (upstream_addr(1), upstream_addr(2));
3828        let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"refused");
3829        let mut mismatched = upstream_response(&query, RCODE_REFUSED, 0, b"wrong txid");
3830        mismatched[0] ^= 0xFF;
3831        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3832
3833        let (got, _asked) = run_forward_walk(
3834            &[
3835                (first, Some((first, refusal))),
3836                (second, Some((second, mismatched))),
3837            ],
3838            &query,
3839            fallback.clone(),
3840        )
3841        .await;
3842
3843        assert_eq!(got, fallback);
3844    }
3845
3846    /// An upstream SERVFAIL is relayed only when it was the FIRST failure. After an upstream that
3847    /// never answered, Go's first error is a transport error, not a SERVFAIL response, so it
3848    /// synthesizes the SERVFAIL rather than relaying the later upstream's.
3849    #[tokio::test]
3850    async fn silence_then_servfail_is_the_fallback_not_the_upstream_servfail() {
3851        let query = build_query(0x20D, &["api", "example", "com"], 1, 1);
3852        let (first, second) = (upstream_addr(1), upstream_addr(2));
3853        let servfail = upstream_response(&query, RCODE_SERVFAIL, 0, b"second servfail");
3854        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3855
3856        let (got, asked) = run_forward_walk(
3857            &[(first, None), (second, Some((second, servfail.clone())))],
3858            &query,
3859            fallback.clone(),
3860        )
3861        .await;
3862
3863        assert_eq!(asked, vec![first, second]);
3864        assert_eq!(got, fallback);
3865        assert_ne!(got, servfail);
3866    }
3867
3868    /// The mirror of the case above, and the only branch that still relays an upstream's bytes once
3869    /// a non-refusal has been seen: the FIRST failure is an upstream SERVFAIL and the second
3870    /// upstream is silent. The held SERVFAIL is relayed verbatim, so its RFC 8914 extended DNS error
3871    /// survives rather than being thrown away for the synthesized one.
3872    ///
3873    /// No other test reaches that branch with `saw_non_refused` set by an upstream that sent
3874    /// nothing at all, so this one pins that a later failure carrying no bytes neither replaces the
3875    /// held SERVFAIL nor demotes the forward to `fallback`.
3876    #[tokio::test]
3877    async fn servfail_then_silent_upstream_relays_the_upstream_servfail() {
3878        let query = build_query(0x20e, &["api", "example", "com"], 1, 1);
3879        let (first, second) = (upstream_addr(1), upstream_addr(2));
3880        let servfail = upstream_response(&query, RCODE_SERVFAIL, 0, b"first servfail");
3881        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3882
3883        let (got, asked) = run_forward_walk(
3884            &[(first, Some((first, servfail.clone()))), (second, None)],
3885            &query,
3886            fallback,
3887        )
3888        .await;
3889
3890        assert_eq!(
3891            asked,
3892            vec![first, second],
3893            "the walk must ask both upstreams"
3894        );
3895        assert_eq!(
3896            got, servfail,
3897            "the first failure's own SERVFAIL bytes are relayed, not the synthesized fallback"
3898        );
3899    }
3900
3901    /// An upstream's own soft error is relayed as it was read, as Go relays `rcodeResponseError.res`:
3902    /// no `TC` for the client's advertised size. This query advertises no EDNS size, so a client
3903    /// limit of 512 applies to an *answer*; a REFUSED padded past it — as a long RFC 8914 extended
3904    /// error text would pad it — still reaches the client whole and with `TC` clear.
3905    #[tokio::test]
3906    async fn a_relayed_soft_error_is_not_marked_truncated_for_the_client_size() {
3907        let query = build_query(0x41A, &["example", "com"], 1, 1);
3908        let only = upstream_addr(1);
3909        let refusal = upstream_response(&query, RCODE_REFUSED, 0, &[0xEE; 700]);
3910        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3911
3912        let (got, _asked) = run_forward_walk(
3913            &[(only, Some((only, refusal.clone())))],
3914            &query,
3915            fallback.clone(),
3916        )
3917        .await;
3918
3919        assert_eq!(got, refusal, "relayed byte for byte");
3920        assert_eq!(got[2] & 0x02, 0, "TC clear, as Go sends it");
3921
3922        // The one bound kept is the datagram's, and it is Go's own for the UDP hop: `sendUDP` cuts
3923        // what it read to `maxResponseBytes` before the RCODE switch returns — without setting TC.
3924        let mut full_ring = upstream_response(&query, RCODE_SERVFAIL, 0, b"");
3925        full_ring.resize(MAX_UPSTREAM_RESPONSE + 1, 0xEE);
3926        let (got, _asked) =
3927            run_forward_walk(&[(only, Some((only, full_ring.clone())))], &query, fallback).await;
3928
3929        assert_eq!(
3930            got,
3931            full_ring[..MAX_UPSTREAM_RESPONSE],
3932            "cut to one datagram"
3933        );
3934        assert_eq!(got[2] & 0x02, 0, "and still not marked truncated");
3935    }
3936
3937    /// Off a datagram entirely — fetched over TCP for a TCP client — a relayed soft error is not cut
3938    /// at all.
3939    #[test]
3940    fn a_soft_error_fetched_over_tcp_for_a_tcp_client_is_relayed_whole() {
3941        let query = build_query(0x41B, &["example", "com"], 1, 1);
3942        let mut big = upstream_response(&query, RCODE_SERVFAIL, 0, b"");
3943        big.resize(MAX_UPSTREAM_RESPONSE + 5000, 0xEE);
3944
3945        let out = relay_soft_error(
3946            UpstreamAnswer {
3947                from: upstream_addr(1),
3948                resp: big.clone(),
3949                via: UpstreamTransport::Tcp,
3950            },
3951            ClientTransport::Tcp,
3952        );
3953        assert_eq!(out, big);
3954    }
3955
3956    /// A discarded datagram counts as a non-refusal failure whatever it claims to carry: a spoofed
3957    /// NOERROR answer with a transaction id we never asked with is neither relayed nor mistaken for
3958    /// the walk's end, and still stops an earlier refusal from being relayed.
3959    #[tokio::test]
3960    async fn refusal_then_discarded_answer_is_the_fallback_not_the_refusal() {
3961        let query = build_query(0x20E, &["api", "example", "com"], 1, 1);
3962        let (first, second) = (upstream_addr(1), upstream_addr(2));
3963        let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"first refusal");
3964        let mut poisoned = upstream_response(&query, 0, 1, b"injected");
3965        poisoned[0] ^= 0xFF; // a transaction id we never asked with
3966        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3967
3968        let (got, _asked) = run_forward_walk(
3969            &[
3970                (first, Some((first, refusal))),
3971                (second, Some((second, poisoned))),
3972            ],
3973            &query,
3974            fallback.clone(),
3975        )
3976        .await;
3977
3978        assert_eq!(got, fallback);
3979    }
3980
3981    #[test]
3982    fn longest_suffix_route_wins() {
3983        let mut routes = std::collections::BTreeMap::new();
3984        routes.insert("example".to_string(), vec![udp("10.0.0.1:53")]);
3985        routes.insert("corp.example".to_string(), vec![udp("10.0.0.2:53")]);
3986        let view = view_with_routes(routes, vec![], vec![]);
3987        let buf = build_query(0x101, &["api", "corp", "example"], 1, 1);
3988
3989        match decide(&view, &buf).expect("decides") {
3990            Decision::Forward { upstreams, .. } => {
3991                assert_eq!(
3992                    upstreams,
3993                    vec!["10.0.0.2:53".parse().unwrap()],
3994                    "longer suffix wins"
3995                );
3996            }
3997            Decision::Reply(_) => panic!("expected forward"),
3998        }
3999    }
4000
4001    #[test]
4002    fn negative_route_is_nxdomain_not_forwarded() {
4003        // An empty upstream list is a negative route: fail closed, never forward.
4004        let mut routes = std::collections::BTreeMap::new();
4005        routes.insert("blocked.example".to_string(), vec![]);
4006        let view = view_with_routes(routes, vec![udp("8.8.8.8:53")], vec![]);
4007        let buf = build_query(0x102, &["x", "blocked", "example"], 1, 1);
4008
4009        match decide(&view, &buf).expect("decides") {
4010            Decision::Reply(resp) => {
4011                let (_, rcode, _) = parse_header(&resp);
4012                assert_eq!(rcode, 3, "NxDomain: negative route is not forwarded");
4013            }
4014            Decision::Forward { .. } => panic!("negative route must not forward"),
4015        }
4016    }
4017
4018    #[test]
4019    fn unrouted_name_forwards_to_fallback_then_global() {
4020        // No route matches: fallback resolvers are preferred over global resolvers.
4021        let view = view_with_routes(
4022            std::collections::BTreeMap::new(),
4023            vec![udp("8.8.8.8:53")],
4024            vec![udp("1.1.1.1:53")],
4025        );
4026        let buf = build_query(0x103, &["example", "com"], 1, 1);
4027
4028        match decide(&view, &buf).expect("decides") {
4029            Decision::Forward { upstreams, .. } => {
4030                assert_eq!(
4031                    upstreams,
4032                    vec!["1.1.1.1:53".parse().unwrap()],
4033                    "fallback preferred"
4034                );
4035            }
4036            Decision::Reply(_) => panic!("expected forward to fallback"),
4037        }
4038    }
4039
4040    #[test]
4041    fn unrouted_name_forwards_to_global_when_no_fallback() {
4042        let view = view_with_routes(
4043            std::collections::BTreeMap::new(),
4044            vec![udp("8.8.8.8:53")],
4045            vec![],
4046        );
4047        let buf = build_query(0x104, &["example", "com"], 1, 1);
4048
4049        match decide(&view, &buf).expect("decides") {
4050            Decision::Forward { upstreams, .. } => {
4051                assert_eq!(upstreams, vec!["8.8.8.8:53".parse().unwrap()]);
4052            }
4053            Decision::Reply(_) => panic!("expected forward to global resolver"),
4054        }
4055    }
4056
4057    #[test]
4058    fn tailnet_name_is_never_forwarded() {
4059        // Anti-leak: a name under a tailnet search domain that has no overlay match must fail
4060        // closed to NXDOMAIN, never leak to an upstream resolver, even with resolvers configured.
4061        let view = view_with_routes(
4062            std::collections::BTreeMap::new(),
4063            vec![udp("8.8.8.8:53")],
4064            vec![udp("1.1.1.1:53")],
4065        );
4066        // "ghost.user.ts.net" is under the tailnet suffix but matches no peer.
4067        let buf = build_query(0x105, &["ghost", "user", "ts", "net"], 1, 1);
4068
4069        match decide(&view, &buf).expect("decides") {
4070            Decision::Reply(resp) => {
4071                let (_, rcode, _) = parse_header(&resp);
4072                assert_eq!(rcode, 3, "NxDomain: tailnet name not leaked upstream");
4073            }
4074            Decision::Forward { .. } => panic!("tailnet name must never be forwarded"),
4075        }
4076    }
4077
4078    #[test]
4079    fn no_resolvers_off_tailnet_is_servfail_not_nxdomain() {
4080        // No route, no resolvers: an OFF-tailnet name cannot be forwarded. Go answers SERVFAIL
4081        // (forwarder.go:1207 "no upstream resolvers set, returning SERVFAIL"), NOT NXDOMAIN — a
4082        // cacheable non-existence for a real name we merely couldn't forward would poison downstream
4083        // stub caches. We still never forward (the name does not leak); we just soft-fail.
4084        let view = view_with_routes(std::collections::BTreeMap::new(), vec![], vec![]);
4085        let buf = build_query(0x106, &["example", "com"], 1, 1);
4086
4087        match decide(&view, &buf).expect("decides") {
4088            Decision::Reply(resp) => {
4089                let (_, rcode, _) = parse_header(&resp);
4090                assert_eq!(
4091                    rcode, 2,
4092                    "ServFail: off-tailnet name with no upstream to forward to"
4093                );
4094            }
4095            Decision::Forward { .. } => panic!("must not forward with no resolvers"),
4096        }
4097    }
4098
4099    #[test]
4100    fn route_with_only_ipv6_upstreams_off_tailnet_is_servfail() {
4101        // A split-DNS route exists but every resolver is IPv6 (filtered out under the IPv4-only
4102        // egress): we have a route yet nowhere to forward. That is an inability to forward an
4103        // off-tailnet name, so SERVFAIL (soft), not a fabricated NXDOMAIN.
4104        let mut routes = std::collections::BTreeMap::new();
4105        routes.insert("corp.example".to_string(), vec![udp("[2001:db8::53]:53")]);
4106        let view = view_with_routes(routes, vec![], vec![]);
4107        let buf = build_query(0x108, &["host", "corp", "example"], 1, 1);
4108
4109        match decide(&view, &buf).expect("decides") {
4110            Decision::Reply(resp) => {
4111                let (_, rcode, _) = parse_header(&resp);
4112                assert_eq!(
4113                    rcode, 2,
4114                    "ServFail: route's resolvers all filtered out (IPv6-only), cannot forward"
4115                );
4116            }
4117            Decision::Forward { .. } => panic!("must not forward when all upstreams are filtered"),
4118        }
4119    }
4120
4121    /// The SERVFAIL this node synthesizes is Go's `servfailResponse`: the query's own header with
4122    /// QR, AA and RCODE set, and nothing else changed. RA stays as the client sent it — clear, for
4123    /// the RD=1 query every stub resolver sends — where a locally built answer would set it; the
4124    /// AD and CD bits and the opcode survive; Z is dropped. The same bytes stand in both for "no
4125    /// upstream to ask" and, as the forward's fallback, for "every upstream failed".
4126    #[test]
4127    fn a_synthesized_servfail_copies_the_query_header_as_go_does() {
4128        let mut buf = build_query(0x10A, &["example", "com"], 1, 1);
4129        buf[2] = 0x01; // RD
4130        buf[3] = 0x70; // Z | AD | CD, RA clear
4131        let check = |resp: &[u8], what: &str| {
4132            assert_eq!(&resp[..2], &buf[..2], "{what}: the query's id");
4133            assert_eq!(
4134                resp[2], 0x85,
4135                "{what}: QR | AA | RD — nothing else in the high byte"
4136            );
4137            assert_eq!(
4138                resp[3], 0x32,
4139                "{what}: AD | CD as sent, RA not set, Z dropped, RCODE = SERVFAIL"
4140            );
4141            assert_eq!(&resp[4..6], &[0, 1], "{what}: one question, echoed");
4142            assert_eq!(&resp[6..12], &[0; 6], "{what}: no other records");
4143            assert_eq!(&resp[12..], &buf[12..], "{what}: the question as asked");
4144        };
4145
4146        let view = view_with_routes(std::collections::BTreeMap::new(), vec![], vec![]);
4147        match decide(&view, &buf).expect("decides") {
4148            Decision::Reply(resp) => check(&resp, "no upstream"),
4149            Decision::Forward { .. } => panic!("must not forward with no resolvers"),
4150        }
4151
4152        let view = view_with_routes(
4153            std::collections::BTreeMap::new(),
4154            vec![udp("192.0.2.53:53")],
4155            vec![],
4156        );
4157        match decide(&view, &buf).expect("decides") {
4158            Decision::Forward { servfail, .. } => check(&servfail, "forward fallback"),
4159            Decision::Reply(_) => panic!("an off-tailnet name with a resolver is forwarded"),
4160        }
4161
4162        // A non-QUERY opcode and a TC bit on the query are copied too.
4163        buf[2] = 0x13; // opcode 2 (STATUS) | TC | RD
4164        let view = view_with_routes(std::collections::BTreeMap::new(), vec![], vec![]);
4165        match decide(&view, &buf).expect("decides") {
4166            Decision::Reply(resp) => assert_eq!(resp[2], 0x80 | 0x10 | 0x04 | 0x03),
4167            Decision::Forward { .. } => panic!("must not forward with no resolvers"),
4168        }
4169    }
4170
4171    #[test]
4172    fn overlay_match_wins_over_forwarding() {
4173        // A known peer name resolves authoritatively even when upstream resolvers are configured.
4174        let mut db = PeerDb::default();
4175        db.upsert(&test_node());
4176        let view = DnsView {
4177            cfg: DnsConfig {
4178                magic_dns: true,
4179                search_domains: vec!["user.ts.net".to_string()],
4180                resolvers: vec![udp("8.8.8.8:53")],
4181                ..Default::default()
4182            },
4183            peers: Some(Arc::new(db)),
4184            self_node: None,
4185            exit_doh: None,
4186            enable_ipv6: false,
4187            accept_dns: true,
4188        };
4189        let buf = build_query(0x107, &["host", "user", "ts", "net"], 1, 1);
4190
4191        match decide(&view, &buf).expect("decides") {
4192            Decision::Reply(resp) => {
4193                let (_, rcode, ancount) = parse_header(&resp);
4194                assert_eq!(rcode, 0, "authoritative answer wins");
4195                assert_eq!(ancount, 1);
4196            }
4197            Decision::Forward { .. } => panic!("overlay match must not forward"),
4198        }
4199    }
4200
4201    #[test]
4202    fn ipv6_reverse_ptr_is_nxdomain_not_forwarded() {
4203        // Anti-leak: an `ip6.arpa` reverse PTR for a tailnet ULA (fd7a:…) must fail closed to
4204        // NXDOMAIN, never be forwarded — even with an upstream resolver configured. This fork is
4205        // IPv4-only on the tailnet; forwarding would reveal that a v6 address was probed.
4206        let view = view_with_routes(
4207            std::collections::BTreeMap::new(),
4208            vec![udp("8.8.8.8:53")],
4209            vec![udp("1.1.1.1:53")],
4210        );
4211        // Reverse name for fd7a::1 (nibble-reversed) under ip6.arpa. The exact nibble labels don't
4212        // matter to the guard — any name ending in ip6.arpa must fail closed.
4213        let labels = vec![
4214            "1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0",
4215            "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "a", "7", "d", "f", "ip6",
4216            "arpa",
4217        ];
4218        let buf = build_query(0x200, &labels, 12, 1);
4219
4220        match decide(&view, &buf).expect("decides") {
4221            Decision::Reply(resp) => {
4222                let (_, rcode, _) = parse_header(&resp);
4223                assert_eq!(
4224                    rcode, 3,
4225                    "NxDomain: ip6.arpa reverse must not leak upstream"
4226                );
4227            }
4228            Decision::Forward { .. } => panic!("ip6.arpa PTR must never be forwarded"),
4229        }
4230    }
4231
4232    /// Both transports a client can reach this node over, for the race rules that hold for either.
4233    const BOTH_CLIENTS: [ClientTransport; 2] = [ClientTransport::Udp, ClientTransport::Tcp];
4234
4235    /// A UDP hop that hears nothing and gives up the way [`ask_upstream_udp`] does: after
4236    /// [`UPSTREAM_TIMEOUT`], with `None`. A resolver whose UDP path drops or stalls.
4237    async fn silent_udp_hop() -> Option<(SocketAddr, Vec<u8>)> {
4238        tokio::time::sleep(UPSTREAM_TIMEOUT).await;
4239        None
4240    }
4241
4242    /// The retry this module exists for: an upstream answer with the `TC` bit set is re-asked over
4243    /// TCP, and the TCP answer — not the truncated datagram — is what the walk relays.
4244    ///
4245    /// Two of Go's bounds are pinned here alongside it. The retry goes to the **same** resolver
4246    /// (walking on would hand the query to a resolver the first one already answered), which is why
4247    /// this asserts on `from`; and it re-sends the **same** query, which is why the TCP hop records
4248    /// the bytes it was asked for.
4249    #[tokio::test]
4250    async fn a_truncated_udp_answer_is_retried_over_tcp_to_the_same_resolver() {
4251        let query = build_query(0x400, &["big", "example", "com"], 16, 1);
4252        let upstream = upstream_addr(1);
4253        // What the UDP hop can carry: the resolver cut the record set and said so.
4254        let mut truncated = upstream_response(&query, 0, 1, b"the part that fit");
4255        truncated[2] |= 0x02; // TC
4256        // What TCP can carry: the whole set, far past what any datagram here could hold.
4257        let whole = upstream_response(&query, 0, 40, &[0xAB; 8000]);
4258
4259        for client in BOTH_CLIENTS {
4260            let retries = std::cell::Cell::new(0);
4261            let answer = race_udp_and_tcp(
4262                upstream,
4263                &query,
4264                client,
4265                TcpRetry::Enabled,
4266                || std::future::ready(Some((upstream, truncated.clone()))),
4267                || {
4268                    retries.set(retries.get() + 1);
4269                    std::future::ready(Some(whole.clone()))
4270                },
4271            )
4272            .await
4273            .expect("the upstream answered");
4274
4275            assert_eq!(
4276                answer.resp, whole,
4277                "{client:?}: the TCP answer replaces the truncated datagram"
4278            );
4279            assert_eq!(
4280                answer.via,
4281                UpstreamTransport::Tcp,
4282                "{client:?}: and is labelled as having come over TCP, so the relay cap knows not to \
4283                 chop it"
4284            );
4285            assert_eq!(
4286                answer.from, upstream,
4287                "{client:?}: the retry goes to the SAME resolver, never on to the next one"
4288            );
4289            assert_eq!(
4290                retries.get(),
4291                1,
4292                "{client:?}: one retry, not one per upstream and not one per attempt"
4293            );
4294        }
4295    }
4296
4297    /// Go falls back to the truncated UDP response when the TCP retry fails, rather than to
4298    /// SERVFAIL. A truncated answer still has an intact header and question and a `TC` bit the stub
4299    /// resolver can act on; a synthesized failure is strictly less than we already had.
4300    #[tokio::test]
4301    async fn a_failed_tcp_retry_relays_the_truncated_udp_answer() {
4302        let query = build_query(0x401, &["big", "example", "com"], 16, 1);
4303        let upstream = upstream_addr(1);
4304        let mut truncated = upstream_response(&query, 0, 1, b"the part that fit");
4305        truncated[2] |= 0x02; // TC
4306
4307        for client in BOTH_CLIENTS {
4308            let answer = race_udp_and_tcp(
4309                upstream,
4310                &query,
4311                client,
4312                TcpRetry::Enabled,
4313                || std::future::ready(Some((upstream, truncated.clone()))),
4314                // The resolver refused the connection, reset it, or never framed an answer.
4315                || std::future::ready(None),
4316            )
4317            .await
4318            .expect("a failed retry must not lose the answer we already had");
4319
4320            assert_eq!(
4321                answer.resp, truncated,
4322                "{client:?}: the truncated UDP answer is relayed, not a synthesized failure"
4323            );
4324            assert_eq!(
4325                answer.via,
4326                UpstreamTransport::Udp,
4327                "{client:?}: and it is still a datagram answer, so the relay cap still applies to it"
4328            );
4329        }
4330    }
4331
4332    /// A reply that fit is never retried: the retry exists for the answer that did not, and for
4333    /// nothing else. For a UDP client, whose UDP hop has the resolver to itself for
4334    /// [`UDP_RACE_TIMEOUT`], a prompt answer that fit means no TCP connection at all — one per
4335    /// forwarded query would double the work this node makes every upstream resolver do.
4336    #[tokio::test]
4337    async fn an_answer_that_is_not_truncated_is_never_retried() {
4338        let query = build_query(0x402, &["example", "com"], 1, 1);
4339        let upstream = upstream_addr(1);
4340        let fits = upstream_response(&query, 0, 1, b"a small answer");
4341
4342        let retried = std::cell::Cell::new(false);
4343        let answer = race_udp_and_tcp(
4344            upstream,
4345            &query,
4346            ClientTransport::Udp,
4347            TcpRetry::Enabled,
4348            || std::future::ready(Some((upstream, fits.clone()))),
4349            || {
4350                retried.set(true);
4351                std::future::ready(None)
4352            },
4353        )
4354        .await
4355        .expect("the upstream answered");
4356
4357        assert!(
4358            !retried.get(),
4359            "an untruncated answer must not open a TCP connection"
4360        );
4361        assert_eq!(answer.resp, fits);
4362        assert_eq!(answer.via, UpstreamTransport::Udp);
4363    }
4364
4365    /// A TCP client gets no UDP head start — Go's race timeout is 0 for it — so its TCP hop is
4366    /// already running when the UDP answer lands. An answer that fit still wins: the TCP hop is
4367    /// dropped, not waited for, and its answer never replaces the one UDP brought.
4368    #[tokio::test(start_paused = true)]
4369    async fn a_tcp_client_races_both_hops_and_an_answer_that_fit_still_wins() {
4370        let query = build_query(0x409, &["example", "com"], 1, 1);
4371        let upstream = upstream_addr(1);
4372        let fits = upstream_response(&query, 0, 1, b"a small answer");
4373        let started = tokio::time::Instant::now();
4374
4375        let tcp_started_at = std::cell::Cell::new(None);
4376        let answer = race_udp_and_tcp(
4377            upstream,
4378            &query,
4379            ClientTransport::Tcp,
4380            TcpRetry::Enabled,
4381            async || {
4382                tokio::time::sleep(Duration::from_millis(100)).await;
4383                Some((upstream, fits.clone()))
4384            },
4385            || {
4386                tcp_started_at.set(Some(started.elapsed()));
4387                // A TCP hop still connecting when UDP answers.
4388                std::future::pending()
4389            },
4390        )
4391        .await
4392        .expect("the upstream answered");
4393
4394        assert_eq!(
4395            tcp_started_at.get(),
4396            Some(Duration::ZERO),
4397            "a TCP client's TCP hop starts alongside the UDP hop, not after a head start"
4398        );
4399        assert_eq!(answer.resp, fits, "the answer that fit wins the race");
4400        assert_eq!(answer.via, UpstreamTransport::Udp);
4401        assert_eq!(
4402            started.elapsed(),
4403            Duration::from_millis(100),
4404            "and the race ends when it lands, without waiting on the TCP hop"
4405        );
4406    }
4407
4408    /// Behaviour (1) of Go's race: a resolver that never answers over UDP but answers over TCP
4409    /// still resolves — once the UDP hop's head start runs out, not once [`UPSTREAM_TIMEOUT`] does.
4410    /// Before the race, the UDP hop was waited out in full and returned nothing, so the walk moved on
4411    /// to the next resolver or ended in SERVFAIL without TCP ever being tried.
4412    #[tokio::test(start_paused = true)]
4413    async fn a_silent_udp_hop_falls_back_to_tcp_after_the_race_timeout() {
4414        let query = build_query(0x40A, &["example", "com"], 1, 1);
4415        let upstream = upstream_addr(1);
4416        let over_tcp = upstream_response(&query, 0, 1, b"answered over tcp");
4417
4418        for (client, head_start) in [
4419            (ClientTransport::Udp, UDP_RACE_TIMEOUT),
4420            (ClientTransport::Tcp, Duration::ZERO),
4421        ] {
4422            let started = tokio::time::Instant::now();
4423            let tcp_started_at = std::cell::Cell::new(None);
4424            let answer = race_udp_and_tcp(
4425                upstream,
4426                &query,
4427                client,
4428                TcpRetry::Enabled,
4429                silent_udp_hop,
4430                || {
4431                    tcp_started_at.set(Some(started.elapsed()));
4432                    std::future::ready(Some(over_tcp.clone()))
4433                },
4434            )
4435            .await
4436            .expect("a resolver that answers over TCP resolves");
4437
4438            assert_eq!(answer.resp, over_tcp, "{client:?}");
4439            assert_eq!(answer.via, UpstreamTransport::Tcp, "{client:?}");
4440            assert_eq!(answer.from, upstream, "{client:?}: the same resolver");
4441            assert_eq!(
4442                tcp_started_at.get(),
4443                Some(head_start),
4444                "{client:?}: TCP joins the race when the UDP hop's head start runs out"
4445            );
4446            assert!(
4447                started.elapsed() < UPSTREAM_TIMEOUT,
4448                "{client:?}: the answer must not wait out the UDP hop's own timeout ({:?})",
4449                started.elapsed()
4450            );
4451        }
4452    }
4453
4454    /// A UDP hop that has already **failed** does not sit out its head start: Go's `Race.Start`
4455    /// starts the fallback the moment the first function returns an error, and so does this.
4456    #[tokio::test(start_paused = true)]
4457    async fn a_failed_udp_hop_starts_tcp_at_once() {
4458        let query = build_query(0x40B, &["example", "com"], 1, 1);
4459        let upstream = upstream_addr(1);
4460        let over_tcp = upstream_response(&query, 0, 1, b"answered over tcp");
4461        let started = tokio::time::Instant::now();
4462
4463        let answer = race_udp_and_tcp(
4464            upstream,
4465            &query,
4466            ClientTransport::Udp,
4467            TcpRetry::Enabled,
4468            // Bind or send failed: nothing will come back.
4469            || std::future::ready(None),
4470            || std::future::ready(Some(over_tcp.clone())),
4471        )
4472        .await
4473        .expect("the TCP hop answered");
4474
4475        assert_eq!(answer.resp, over_tcp);
4476        assert_eq!(answer.via, UpstreamTransport::Tcp);
4477        assert_eq!(
4478            started.elapsed(),
4479            Duration::ZERO,
4480            "no head start is waited out for a hop that already failed"
4481        );
4482
4483        // And when both hops fail there is no answer, so the walk moves on.
4484        let answer = race_udp_and_tcp(
4485            upstream,
4486            &query,
4487            ClientTransport::Udp,
4488            TcpRetry::Enabled,
4489            || std::future::ready(None),
4490            || std::future::ready(None),
4491        )
4492        .await;
4493        assert!(answer.is_err(), "both hops failed: nothing to relay");
4494    }
4495
4496    /// The race is symmetric: a TCP hop that fails first leaves it to the UDP hop, which is waited
4497    /// for rather than abandoned.
4498    #[tokio::test(start_paused = true)]
4499    async fn a_failed_tcp_hop_leaves_the_race_to_the_udp_hop() {
4500        let query = build_query(0x40C, &["example", "com"], 1, 1);
4501        let upstream = upstream_addr(1);
4502        let over_udp = upstream_response(&query, 0, 1, b"answered over udp");
4503
4504        let answer = race_udp_and_tcp(
4505            upstream,
4506            &query,
4507            ClientTransport::Tcp,
4508            TcpRetry::Enabled,
4509            async || {
4510                tokio::time::sleep(Duration::from_secs(1)).await;
4511                Some((upstream, over_udp.clone()))
4512            },
4513            || std::future::ready(None),
4514        )
4515        .await
4516        .expect("the UDP hop answered after the TCP hop failed");
4517
4518        assert_eq!(answer.resp, over_udp);
4519        assert_eq!(answer.via, UpstreamTransport::Udp);
4520    }
4521
4522    /// The race sits inside the walk, not beside it: a first resolver that is silent over UDP but
4523    /// answers over TCP is the resolver that answers. The walk never reaches the second one, and the
4524    /// client has its answer after the head start, not after the UDP hop's full timeout.
4525    #[tokio::test(start_paused = true)]
4526    async fn a_resolver_silent_over_udp_answers_the_walk_over_tcp() {
4527        let query = build_query(0x40D, &["example", "com"], 1, 1);
4528        let first = upstream_addr(1);
4529        let second = upstream_addr(2);
4530        let over_tcp = upstream_response(&query, 0, 1, b"the first resolver over tcp");
4531        let started = tokio::time::Instant::now();
4532
4533        let asked = std::cell::RefCell::new(Vec::new());
4534        let response = forward_walk(
4535            &[first, second],
4536            &query,
4537            servfail_response(),
4538            ClientTransport::Udp,
4539            |upstream| {
4540                asked.borrow_mut().push(upstream);
4541                let over_tcp = over_tcp.clone();
4542                race_udp_and_tcp(
4543                    upstream,
4544                    &query,
4545                    ClientTransport::Udp,
4546                    TcpRetry::Enabled,
4547                    silent_udp_hop,
4548                    move || std::future::ready(Some(over_tcp)),
4549                )
4550            },
4551        )
4552        .await;
4553
4554        assert_eq!(response, over_tcp);
4555        assert_eq!(
4556            asked.into_inner(),
4557            vec![first],
4558            "the resolver that answered over TCP ends the walk"
4559        );
4560        assert_eq!(started.elapsed(), UDP_RACE_TIMEOUT);
4561    }
4562
4563    /// The other way an answer did not fit: the upstream sent one this forwarder cannot relay in a
4564    /// single datagram ([`MAX_UPSTREAM_RESPONSE`]), with `TC` clear because it fit the resolver's
4565    /// own idea of a datagram. [`cap_response`] is about to cut it and set `TC` — so the retry has
4566    /// to fire on it too, or that name resolves only ever as a cut answer. Go detects the same
4567    /// case by reading into a `maxResponseBytes+1` buffer and setting `TC` on the reply it hands
4568    /// its own truncation check.
4569    #[tokio::test]
4570    async fn an_oversize_answer_is_retried_even_with_tc_clear() {
4571        let query = build_edns_query(0x407, &["big", "example", "com"], 16, 1, 4096);
4572        let upstream = upstream_addr(1);
4573        // The one oversize datagram the netstack's 4096-byte receive ring can deliver, TC clear.
4574        let mut full_ring = upstream_response(&query, 0, 1, b"");
4575        full_ring.resize(MAX_UPSTREAM_RESPONSE + 1, 0xAB);
4576        assert_eq!(
4577            full_ring[2] & 0x02,
4578            0,
4579            "the upstream did not mark it truncated"
4580        );
4581        let whole = upstream_response(&query, 0, 40, &[0xAB; 8000]);
4582
4583        for client in BOTH_CLIENTS {
4584            let answer = race_udp_and_tcp(
4585                upstream,
4586                &query,
4587                client,
4588                TcpRetry::Enabled,
4589                || std::future::ready(Some((upstream, full_ring.clone()))),
4590                || std::future::ready(Some(whole.clone())),
4591            )
4592            .await
4593            .expect("the upstream answered");
4594
4595            assert_eq!(
4596                answer.resp, whole,
4597                "{client:?}: an answer too big for one datagram is re-asked over TCP, TC bit or no \
4598                 TC bit"
4599            );
4600            assert_eq!(answer.via, UpstreamTransport::Tcp);
4601        }
4602    }
4603
4604    /// `dns-forwarder-disable-tcp-retries` is the TCP hop's **off** switch, and the polarity is the
4605    /// whole point: a node control never set it on keeps using TCP. Read from the self node through
4606    /// the view, and honoured at the one place that would open the connection — on **every** arm of
4607    /// the race: not for a truncated answer, not for a silent UDP hop once its head start runs out,
4608    /// and not alongside UDP for a TCP client.
4609    #[tokio::test(start_paused = true)]
4610    async fn the_node_attribute_turns_the_tcp_retry_off() {
4611        let mut view = view_with_peer();
4612        assert_eq!(
4613            view.upstream_tcp_retry(),
4614            TcpRetry::Enabled,
4615            "no self node yet ⇒ the retry is on, because on is the default"
4616        );
4617
4618        let mut node = test_node();
4619        view.self_node = Some(node.clone());
4620        assert_eq!(
4621            view.upstream_tcp_retry(),
4622            TcpRetry::Enabled,
4623            "a self node without the attribute ⇒ still on"
4624        );
4625
4626        node.cap_map
4627            .insert("dns-forwarder-disable-tcp-retries".to_string(), vec![]);
4628        view.self_node = Some(node);
4629        assert_eq!(
4630            view.upstream_tcp_retry(),
4631            TcpRetry::Disabled,
4632            "the attribute control sets is what turns it off"
4633        );
4634
4635        let query = build_query(0x403, &["big", "example", "com"], 16, 1);
4636        let upstream = upstream_addr(1);
4637        let mut truncated = upstream_response(&query, 0, 1, b"the part that fit");
4638        truncated[2] |= 0x02; // TC
4639
4640        for client in BOTH_CLIENTS {
4641            // With it off, a truncated answer is relayed as it came — no TCP connection at all.
4642            let retried = std::cell::Cell::new(false);
4643            let answer = race_udp_and_tcp(
4644                upstream,
4645                &query,
4646                client,
4647                view.upstream_tcp_retry(),
4648                || std::future::ready(Some((upstream, truncated.clone()))),
4649                || {
4650                    retried.set(true);
4651                    std::future::ready(None)
4652                },
4653            )
4654            .await
4655            .expect("the upstream answered");
4656
4657            assert!(
4658                !retried.get(),
4659                "{client:?}: the attribute must stop the TCP retry being made at all"
4660            );
4661            assert_eq!(
4662                answer.resp, truncated,
4663                "{client:?}: relayed truncated, as control asked"
4664            );
4665            assert_eq!(answer.via, UpstreamTransport::Udp);
4666
4667            // And a UDP hop that never answers is waited out, not raced: no fallback either.
4668            let started = tokio::time::Instant::now();
4669            let raced = std::cell::Cell::new(false);
4670            let answer = race_udp_and_tcp(
4671                upstream,
4672                &query,
4673                client,
4674                view.upstream_tcp_retry(),
4675                silent_udp_hop,
4676                || {
4677                    raced.set(true);
4678                    std::future::ready(Some(truncated.clone()))
4679                },
4680            )
4681            .await;
4682
4683            assert!(
4684                !raced.get(),
4685                "{client:?}: the attribute must keep the TCP hop out of the race too"
4686            );
4687            assert!(answer.is_err(), "{client:?}: UDP said nothing, so nothing");
4688            assert_eq!(
4689                started.elapsed(),
4690                UPSTREAM_TIMEOUT,
4691                "{client:?}: the UDP hop runs to its own timeout, as it did before the race"
4692            );
4693        }
4694    }
4695
4696    /// An off-path injector must not be able to buy a TCP connection to a resolver with a datagram
4697    /// it forged. A `TC`-marked datagram from the wrong source, or one that does not echo the
4698    /// question we asked, is relayed to [`forward_walk`] exactly as before — which discards it — and
4699    /// costs no connection on the way.
4700    #[tokio::test]
4701    async fn a_forged_truncated_datagram_does_not_buy_a_tcp_connection() {
4702        let query = build_query(0x404, &["example", "com"], 1, 1);
4703        let upstream = upstream_addr(1);
4704
4705        let retried = std::cell::Cell::new(false);
4706        let mut tcp_hop = || {
4707            retried.set(true);
4708            std::future::ready(None)
4709        };
4710
4711        let mut from_elsewhere = upstream_response(&query, 0, 1, b"injected");
4712        from_elsewhere[2] |= 0x02; // TC
4713        let answer = race_udp_and_tcp(
4714            upstream,
4715            &query,
4716            ClientTransport::Udp,
4717            TcpRetry::Enabled,
4718            || std::future::ready(Some((upstream_addr(9), from_elsewhere.clone()))),
4719            &mut tcp_hop,
4720        )
4721        .await
4722        .expect("the datagram is still handed on for the walk to discard");
4723        assert!(
4724            !retried.get(),
4725            "a datagram from the wrong source must not be retried"
4726        );
4727        assert_eq!(answer.via, UpstreamTransport::Udp);
4728
4729        let other_question = build_query(0x404, &["other", "example", "com"], 1, 1);
4730        let mut wrong_question = upstream_response(&other_question, 0, 1, b"injected");
4731        wrong_question[2] |= 0x02; // TC
4732        let answer = race_udp_and_tcp(
4733            upstream,
4734            &query,
4735            ClientTransport::Udp,
4736            TcpRetry::Enabled,
4737            || std::future::ready(Some((upstream, wrong_question.clone()))),
4738            &mut tcp_hop,
4739        )
4740        .await
4741        .expect("the datagram is still handed on for the walk to discard");
4742        assert!(
4743            !retried.get(),
4744            "a datagram answering another question must not be retried"
4745        );
4746        assert_eq!(answer.via, UpstreamTransport::Udp);
4747    }
4748
4749    /// Where the TCP hop is already running — a TCP client's, which starts with the UDP hop — a
4750    /// forged datagram does not end the race either: it is not an answer, so the resolver's own
4751    /// TCP answer is what comes back.
4752    #[tokio::test]
4753    async fn a_forged_datagram_does_not_cut_short_a_running_tcp_hop() {
4754        let query = build_query(0x40E, &["example", "com"], 1, 1);
4755        let upstream = upstream_addr(1);
4756        let injected = upstream_response(&query, 0, 1, b"injected");
4757        let over_tcp = upstream_response(&query, 0, 1, b"the resolver over tcp");
4758
4759        let answer = race_udp_and_tcp(
4760            upstream,
4761            &query,
4762            ClientTransport::Tcp,
4763            TcpRetry::Enabled,
4764            || std::future::ready(Some((upstream_addr(9), injected.clone()))),
4765            || std::future::ready(Some(over_tcp.clone())),
4766        )
4767        .await
4768        .expect("the TCP hop answered");
4769
4770        assert_eq!(answer.resp, over_tcp);
4771        assert_eq!(answer.via, UpstreamTransport::Tcp);
4772    }
4773
4774    /// The TCP hop gets the same scrutiny as the datagram, and for a reason the datagram check does
4775    /// not have: the hop that loses this race is **dropped**. A TCP message that does not echo the
4776    /// query is one `forward_walk` will discard, so letting it win takes the UDP answer down with
4777    /// it — the future is cancelled and the answer never arrives — and a query that resolves over
4778    /// UDP comes back SERVFAIL instead. Scored as a failed hop, the race stays with UDP.
4779    #[tokio::test(start_paused = true)]
4780    async fn a_mismatched_tcp_answer_leaves_the_race_to_the_udp_hop() {
4781        let query = build_query(0x410, &["example", "com"], 1, 1);
4782        let upstream = upstream_addr(1);
4783        let over_udp = upstream_response(&query, 0, 1, b"the resolver over udp");
4784        // A resolver answering a question we never asked, framed over the connection we opened for
4785        // this one: `response_matches_query` is what tells the two apart.
4786        let other_question = build_query(0x410, &["other", "example", "com"], 1, 1);
4787        let mismatched = upstream_response(&other_question, 0, 1, b"an answer to something else");
4788
4789        // A TCP client races both hops from the start, so the mismatched message is ready while the
4790        // UDP hop is still in flight — the case where winning would cost the answer.
4791        let answer = race_udp_and_tcp(
4792            upstream,
4793            &query,
4794            ClientTransport::Tcp,
4795            TcpRetry::Enabled,
4796            async || {
4797                tokio::time::sleep(Duration::from_millis(100)).await;
4798                Some((upstream, over_udp.clone()))
4799            },
4800            || std::future::ready(Some(mismatched.clone())),
4801        )
4802        .await
4803        .expect("the UDP hop's answer must survive a mismatched TCP message");
4804
4805        assert_eq!(
4806            answer.resp, over_udp,
4807            "the mismatched TCP message must not win a race the UDP hop was still running"
4808        );
4809        assert_eq!(answer.via, UpstreamTransport::Udp);
4810
4811        // And with nothing else running, a mismatched message is no answer at all: the walk moves
4812        // on to the next resolver rather than relaying it.
4813        let answer = race_udp_and_tcp(
4814            upstream,
4815            &query,
4816            ClientTransport::Udp,
4817            TcpRetry::Enabled,
4818            || std::future::ready(None),
4819            || std::future::ready(Some(mismatched.clone())),
4820        )
4821        .await;
4822
4823        assert!(
4824            answer.is_err(),
4825            "a failed UDP hop plus a mismatched TCP message is not an answer"
4826        );
4827    }
4828
4829    /// The truncation retry has the same hole and the worst outcome of it: a truncated answer is a
4830    /// real answer being *held*, and a mismatched TCP reply that replaced it would lose it outright
4831    /// — the same loss a failed TCP retry is already careful not to cause.
4832    #[tokio::test]
4833    async fn a_mismatched_tcp_retry_relays_the_truncated_udp_answer() {
4834        let query = build_query(0x411, &["big", "example", "com"], 16, 1);
4835        let upstream = upstream_addr(1);
4836        let mut truncated = upstream_response(&query, 0, 1, b"the part that fit");
4837        truncated[2] |= 0x02; // TC
4838        // Same question, wrong transaction id: a stale answer to the previous query on a reused
4839        // connection is the everyday way to get one of these without an attacker.
4840        let mut stale = upstream_response(&query, 0, 40, &[0xAB; 600]);
4841        stale[0] ^= 0xFF;
4842
4843        for client in BOTH_CLIENTS {
4844            let answer = race_udp_and_tcp(
4845                upstream,
4846                &query,
4847                client,
4848                TcpRetry::Enabled,
4849                || std::future::ready(Some((upstream, truncated.clone()))),
4850                || std::future::ready(Some(stale.clone())),
4851            )
4852            .await
4853            .expect("a mismatched retry must not lose the answer we already had");
4854
4855            assert_eq!(
4856                answer.resp, truncated,
4857                "{client:?}: the truncated UDP answer is relayed, not the mismatched TCP message"
4858            );
4859            assert_eq!(answer.via, UpstreamTransport::Udp, "{client:?}");
4860        }
4861    }
4862
4863    /// The other side of the fallback, in the shape of Go's `TestForwarderNetstackUpstream`, which
4864    /// bounds its elapsed time by `udpRaceTimeout`: a resolver whose UDP path works must be answered
4865    /// over UDP, inside [`UDP_RACE_TIMEOUT`], with no TCP hop started. The race would otherwise hide
4866    /// a broken UDP hop behind the right bytes arriving two seconds late over TCP — which is why the
4867    /// clock is checked, not only the bytes.
4868    #[tokio::test(start_paused = true)]
4869    async fn a_working_udp_hop_answers_inside_the_race_timeout_without_tcp() {
4870        let query = build_query(0x40F, &["example", "com"], 1, 1);
4871        let upstream = upstream_addr(1);
4872        let over_udp = upstream_response(&query, 0, 1, b"the resolver over udp");
4873        let started = tokio::time::Instant::now();
4874
4875        let tcp_started = std::cell::Cell::new(false);
4876        let answer = race_udp_and_tcp(
4877            upstream,
4878            &query,
4879            ClientTransport::Udp,
4880            TcpRetry::Enabled,
4881            async || {
4882                // A round trip to a resolver that is up.
4883                tokio::time::sleep(Duration::from_millis(50)).await;
4884                Some((upstream, over_udp.clone()))
4885            },
4886            || {
4887                tcp_started.set(true);
4888                std::future::ready(Some(b"never wanted".to_vec()))
4889            },
4890        )
4891        .await
4892        .expect("the upstream answered");
4893
4894        assert_eq!(answer.resp, over_udp);
4895        assert_eq!(answer.via, UpstreamTransport::Udp);
4896        assert!(
4897            started.elapsed() < UDP_RACE_TIMEOUT,
4898            "a working UDP path answers before TCP would ever be tried ({:?})",
4899            started.elapsed()
4900        );
4901        assert!(!tcp_started.get(), "and no TCP connection is opened");
4902    }
4903
4904    /// A SERVFAIL or REFUSED over UDP is a failed hop, not an answer: Go's `sendUDP` returns it as
4905    /// an `rcodeResponseError`, so `Race.Start` starts the TCP hop to the same resolver at once, and
4906    /// that hop's answer is the resolver's answer. Before this, the datagram won the race and the TCP
4907    /// hop was never tried, so a resolver that soft-fails over UDP but answers over TCP did not
4908    /// resolve here.
4909    #[tokio::test(start_paused = true)]
4910    async fn a_udp_soft_error_starts_the_tcp_hop_at_once_and_its_answer_wins() {
4911        let query = build_query(0x412, &["example", "com"], 1, 1);
4912        let upstream = upstream_addr(1);
4913        let over_tcp = upstream_response(&query, 0, 1, b"answered over tcp");
4914
4915        for rcode in [RCODE_SERVFAIL, RCODE_REFUSED] {
4916            let soft = upstream_response(&query, rcode, 0, b"soft over udp");
4917            for client in BOTH_CLIENTS {
4918                let started = tokio::time::Instant::now();
4919                let tcp_started_at = std::cell::Cell::new(None);
4920                let answer = race_udp_and_tcp(
4921                    upstream,
4922                    &query,
4923                    client,
4924                    TcpRetry::Enabled,
4925                    || std::future::ready(Some((upstream, soft.clone()))),
4926                    || {
4927                        tcp_started_at.set(Some(started.elapsed()));
4928                        std::future::ready(Some(over_tcp.clone()))
4929                    },
4930                )
4931                .await
4932                .expect("the TCP hop answered");
4933
4934                assert_eq!(
4935                    answer.resp, over_tcp,
4936                    "rcode {rcode}, {client:?}: the TCP answer, not the soft error"
4937                );
4938                assert_eq!(answer.via, UpstreamTransport::Tcp);
4939                assert_eq!(answer.from, upstream, "the same resolver");
4940                assert_eq!(
4941                    tcp_started_at.get(),
4942                    Some(Duration::ZERO),
4943                    "rcode {rcode}, {client:?}: no head start is waited out after a soft error"
4944                );
4945            }
4946        }
4947    }
4948
4949    /// When both hops soft-fail the resolver has failed, and the failure is Go's joined error read
4950    /// the way `forwardWithDestChan` reads it: refused if EITHER hop refused, and carrying the soft
4951    /// error of the hop that settled first.
4952    #[tokio::test]
4953    async fn both_hops_soft_failing_is_a_failure_carrying_the_first_soft_error() {
4954        let query = build_query(0x413, &["example", "com"], 1, 1);
4955        let upstream = upstream_addr(1);
4956        let servfail = upstream_response(&query, RCODE_SERVFAIL, 0, b"servfail over udp");
4957        let refused = upstream_response(&query, RCODE_REFUSED, 0, b"refused over tcp");
4958
4959        let failure = race_udp_and_tcp(
4960            upstream,
4961            &query,
4962            ClientTransport::Udp,
4963            TcpRetry::Enabled,
4964            || std::future::ready(Some((upstream, servfail.clone()))),
4965            || std::future::ready(Some(refused.clone())),
4966        )
4967        .await
4968        .expect_err("neither hop answered");
4969        assert!(
4970            failure.refused,
4971            "a REFUSED on either hop makes the resolver refused (errors.Is on the joined error)"
4972        );
4973        assert_eq!(
4974            failure.first_soft.map(|soft| soft.resp),
4975            Some(servfail.clone()),
4976            "the soft error kept is the first to settle — the UDP hop's SERVFAIL"
4977        );
4978
4979        // A soft error and a transport failure: the soft error is all there is to keep, and the
4980        // resolver is refused only if that soft error is a refusal.
4981        for (rcode, refused) in [(RCODE_SERVFAIL, false), (RCODE_REFUSED, true)] {
4982            let soft = upstream_response(&query, rcode, 0, b"soft over udp");
4983            let failure = race_udp_and_tcp(
4984                upstream,
4985                &query,
4986                ClientTransport::Udp,
4987                TcpRetry::Enabled,
4988                || std::future::ready(Some((upstream, soft.clone()))),
4989                || std::future::ready(None),
4990            )
4991            .await
4992            .expect_err("neither hop answered");
4993            assert_eq!(failure.refused, refused, "rcode {rcode}");
4994            assert_eq!(failure.first_soft.map(|soft| soft.resp), Some(soft));
4995        }
4996    }
4997
4998    /// The same rule on the other hop: a SERVFAIL or REFUSED over TCP (Go's `sendTCP` returns it as
4999    /// an `rcodeResponseError`) leaves the race to the UDP hop, which is waited for rather than
5000    /// beaten by the soft error.
5001    #[tokio::test(start_paused = true)]
5002    async fn a_tcp_soft_error_leaves_the_race_to_the_udp_hop() {
5003        let query = build_query(0x414, &["example", "com"], 1, 1);
5004        let upstream = upstream_addr(1);
5005        let over_udp = upstream_response(&query, 0, 1, b"answered over udp");
5006        let soft = upstream_response(&query, RCODE_SERVFAIL, 0, b"servfail over tcp");
5007
5008        // A TCP client races both hops from the start, so the TCP soft error is in while the UDP
5009        // hop is still in flight.
5010        let answer = race_udp_and_tcp(
5011            upstream,
5012            &query,
5013            ClientTransport::Tcp,
5014            TcpRetry::Enabled,
5015            async || {
5016                tokio::time::sleep(Duration::from_millis(100)).await;
5017                Some((upstream, over_udp.clone()))
5018            },
5019            || std::future::ready(Some(soft.clone())),
5020        )
5021        .await
5022        .expect("the UDP hop answered after the TCP hop soft-failed");
5023
5024        assert_eq!(answer.resp, over_udp);
5025        assert_eq!(answer.via, UpstreamTransport::Udp);
5026    }
5027
5028    /// A truncated answer held while TCP retries it is not given up for a soft error over TCP: Go's
5029    /// `send` finds the `truncatedResponseError` in the joined error and returns its bytes.
5030    #[tokio::test]
5031    async fn a_soft_tcp_retry_relays_the_truncated_udp_answer() {
5032        let query = build_query(0x415, &["big", "example", "com"], 16, 1);
5033        let upstream = upstream_addr(1);
5034        let mut truncated = upstream_response(&query, 0, 1, b"the part that fit");
5035        truncated[2] |= 0x02; // TC
5036        let soft = upstream_response(&query, RCODE_SERVFAIL, 0, b"servfail over tcp");
5037
5038        for client in BOTH_CLIENTS {
5039            let answer = race_udp_and_tcp(
5040                upstream,
5041                &query,
5042                client,
5043                TcpRetry::Enabled,
5044                || std::future::ready(Some((upstream, truncated.clone()))),
5045                || std::future::ready(Some(soft.clone())),
5046            )
5047            .await
5048            .expect("a soft-failed retry must not lose the answer we already had");
5049
5050            assert_eq!(answer.resp, truncated, "{client:?}");
5051            assert_eq!(answer.via, UpstreamTransport::Udp, "{client:?}");
5052        }
5053    }
5054
5055    /// With the TCP hop switched off, a soft error over UDP is still a failure and not an answer —
5056    /// and no connection is opened to find a better one.
5057    #[tokio::test]
5058    async fn with_tcp_off_a_udp_soft_error_is_a_failure_and_opens_no_connection() {
5059        let query = build_query(0x416, &["example", "com"], 1, 1);
5060        let upstream = upstream_addr(1);
5061        let refused = upstream_response(&query, RCODE_REFUSED, 0, b"refused over udp");
5062
5063        for client in BOTH_CLIENTS {
5064            let dialled = std::cell::Cell::new(false);
5065            let failure = race_udp_and_tcp(
5066                upstream,
5067                &query,
5068                client,
5069                TcpRetry::Disabled,
5070                || std::future::ready(Some((upstream, refused.clone()))),
5071                || {
5072                    dialled.set(true);
5073                    std::future::ready(None)
5074                },
5075            )
5076            .await
5077            .expect_err("a REFUSED is not an answer");
5078
5079            assert!(!dialled.get(), "{client:?}: TCP is off");
5080            assert!(failure.refused, "{client:?}");
5081            assert_eq!(
5082                failure.first_soft.map(|soft| soft.resp),
5083                Some(refused.clone())
5084            );
5085        }
5086    }
5087
5088    /// One scripted upstream for [`run_forward_walk_over_both_hops`]: its address, the datagram its
5089    /// UDP hop brings back, and the message its TCP hop brings back (`None` = nothing came back).
5090    type TwoHopUpstream = (SocketAddr, Option<Vec<u8>>, Option<Vec<u8>>);
5091
5092    /// Run the real [`forward_walk`] over the real [`race_udp_and_tcp`] for a UDP client, with both
5093    /// hops of each upstream scripted. Returns the client's bytes and the upstreams asked.
5094    async fn run_forward_walk_over_both_hops(
5095        script: &[TwoHopUpstream],
5096        query: &[u8],
5097        fallback: Vec<u8>,
5098    ) -> (Vec<u8>, Vec<SocketAddr>) {
5099        let upstreams: Vec<SocketAddr> = script.iter().map(|(upstream, ..)| *upstream).collect();
5100        let asked = std::cell::RefCell::new(Vec::new());
5101
5102        let response = forward_walk(
5103            &upstreams,
5104            query,
5105            fallback,
5106            ClientTransport::Udp,
5107            |upstream| {
5108                asked.borrow_mut().push(upstream);
5109                let (_, over_udp, over_tcp) = script
5110                    .iter()
5111                    .find(|(scripted, ..)| *scripted == upstream)
5112                    .cloned()
5113                    .expect("a scripted upstream");
5114                race_udp_and_tcp(
5115                    upstream,
5116                    query,
5117                    ClientTransport::Udp,
5118                    TcpRetry::Enabled,
5119                    move || std::future::ready(over_udp.map(|resp| (upstream, resp))),
5120                    move || std::future::ready(over_tcp),
5121                )
5122            },
5123        )
5124        .await;
5125
5126        (response, asked.into_inner())
5127    }
5128
5129    /// Consequence (1) of the audit: a resolver that soft-fails over UDP but answers over TCP
5130    /// resolves, and the walk never hands the query to a second resolver Go would not have asked.
5131    #[tokio::test]
5132    async fn a_resolver_that_soft_fails_over_udp_but_answers_over_tcp_ends_the_walk() {
5133        let query = build_query(0x417, &["example", "com"], 1, 1);
5134        let (first, second) = (upstream_addr(1), upstream_addr(2));
5135        let over_tcp = upstream_response(&query, 0, 1, b"the first resolver over tcp");
5136        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
5137
5138        for rcode in [RCODE_SERVFAIL, RCODE_REFUSED] {
5139            let (got, asked) = run_forward_walk_over_both_hops(
5140                &[
5141                    (
5142                        first,
5143                        Some(upstream_response(&query, rcode, 0, b"soft over udp")),
5144                        Some(over_tcp.clone()),
5145                    ),
5146                    (
5147                        second,
5148                        Some(upstream_response(&query, 0, 1, b"the second resolver")),
5149                        None,
5150                    ),
5151                ],
5152                &query,
5153                fallback.clone(),
5154            )
5155            .await;
5156
5157            assert_eq!(
5158                got, over_tcp,
5159                "rcode {rcode}: the first resolver's TCP answer"
5160            );
5161            assert_eq!(
5162                asked,
5163                vec![first],
5164                "rcode {rcode}: the second is never asked"
5165            );
5166        }
5167    }
5168
5169    /// Consequence (2): the refused tally counts a refusal on either hop. The second resolver's UDP
5170    /// SERVFAIL is followed by a TCP REFUSED, which Go counts as refused, so every resolver refused
5171    /// and the first refusal is relayed. Scored on the UDP datagram alone, the SERVFAIL made it a
5172    /// non-refusal and the client got the synthesized SERVFAIL instead.
5173    #[tokio::test]
5174    async fn a_refusal_on_either_hop_counts_toward_every_upstream_refusing() {
5175        let query = build_query(0x418, &["example", "com"], 1, 1);
5176        let (first, second) = (upstream_addr(1), upstream_addr(2));
5177        let first_refusal = upstream_response(&query, RCODE_REFUSED, 0, b"first refusal");
5178        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
5179
5180        let (got, asked) = run_forward_walk_over_both_hops(
5181            &[
5182                (first, Some(first_refusal.clone()), None),
5183                (
5184                    second,
5185                    Some(upstream_response(
5186                        &query,
5187                        RCODE_SERVFAIL,
5188                        0,
5189                        b"servfail over udp",
5190                    )),
5191                    Some(upstream_response(
5192                        &query,
5193                        RCODE_REFUSED,
5194                        0,
5195                        b"refused over tcp",
5196                    )),
5197                ),
5198            ],
5199            &query,
5200            fallback.clone(),
5201        )
5202        .await;
5203
5204        assert_eq!(asked, vec![first, second]);
5205        assert_eq!(got, first_refusal, "every resolver was refused on some hop");
5206        assert_ne!(got, fallback);
5207    }
5208
5209    /// And the relayed bytes are the first soft error to settle, whatever its code: a lone resolver
5210    /// that answers SERVFAIL over UDP and REFUSED over TCP is refused, and Go relays the SERVFAIL
5211    /// it found first in the joined error.
5212    #[tokio::test]
5213    async fn an_all_refused_forward_relays_the_first_soft_error_to_settle() {
5214        let query = build_query(0x419, &["example", "com"], 1, 1);
5215        let only = upstream_addr(1);
5216        let servfail = upstream_response(&query, RCODE_SERVFAIL, 0, b"servfail over udp");
5217        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
5218
5219        let (got, _asked) = run_forward_walk_over_both_hops(
5220            &[(
5221                only,
5222                Some(servfail.clone()),
5223                Some(upstream_response(
5224                    &query,
5225                    RCODE_REFUSED,
5226                    0,
5227                    b"refused over tcp",
5228                )),
5229            )],
5230            &query,
5231            fallback,
5232        )
5233        .await;
5234
5235        assert_eq!(got, servfail);
5236    }
5237
5238    /// The relay cap is a datagram bound, so the one path it must not chop is the one that never
5239    /// touches a datagram: fetched over the TCP hop, handed to a TCP client. Chopping there would
5240    /// hand the stub resolver the same truncated answer it retried over TCP to escape, and the name
5241    /// would still not resolve through this node.
5242    ///
5243    /// The same answer on its way to a **UDP** client is still cut to one datagram and marked `TC`
5244    /// — it has to fit the datagram it leaves in — which is what sends that client to
5245    /// `dns_over_tcp`, where it is served whole.
5246    #[test]
5247    fn a_tcp_fetched_answer_is_relayed_whole_to_a_tcp_client() {
5248        let query = build_edns_query(0x405, &["big", "example", "com"], 16, 1, 4096);
5249        let mut big = query.clone();
5250        big[2] |= 0x80; // QR = 1
5251        big.resize(MAX_UPSTREAM_RESPONSE + 5000, 0xAB);
5252
5253        let out = cap_response(
5254            &query,
5255            big.clone(),
5256            ClientTransport::Tcp,
5257            UpstreamTransport::Tcp,
5258        );
5259        assert_eq!(
5260            out, big,
5261            "TCP end to end: the whole answer, unchopped and unmarked"
5262        );
5263
5264        let out = cap_response(&query, big, ClientTransport::Udp, UpstreamTransport::Tcp);
5265        assert_eq!(
5266            out.len(),
5267            MAX_UPSTREAM_RESPONSE,
5268            "the same answer still has to fit the datagram a UDP client is answered in"
5269        );
5270        assert_ne!(
5271            out[2] & 0x02,
5272            0,
5273            "and is marked truncated, which is what sends that client to the TCP responder"
5274        );
5275    }
5276
5277    /// The TCP hop's framing, driven end to end over an in-memory stream: the query goes out under
5278    /// a two-byte big-endian length prefix (RFC 1035 §4.2.2) in ONE write, and the answer is read
5279    /// back out from under its own prefix. Framing is what a DNS-over-TCP resolver rejects a
5280    /// connection for, so it is checked against a reader rather than asserted about.
5281    #[tokio::test]
5282    async fn tcp_exchange_frames_the_query_and_reads_the_framed_answer() {
5283        let query = build_query(0x406, &["big", "example", "com"], 16, 1);
5284        let answer = upstream_response(&query, 0, 40, &[0xAB; 8000]);
5285
5286        let (mut client, mut server) = tokio::io::duplex(64 * 1024);
5287        let resolver = {
5288            let query = query.clone();
5289            let answer = answer.clone();
5290            tokio::spawn(async move {
5291                let mut len_buf = [0u8; 2];
5292                server.read_exact(&mut len_buf).await.unwrap();
5293                let len = usize::from(u16::from_be_bytes(len_buf));
5294                assert_eq!(len, query.len(), "the prefix declares the query's length");
5295                let mut got = vec![0u8; len];
5296                server.read_exact(&mut got).await.unwrap();
5297                assert_eq!(got, query, "and the query follows it verbatim");
5298
5299                let mut framed = u16::try_from(answer.len()).unwrap().to_be_bytes().to_vec();
5300                framed.extend_from_slice(&answer);
5301                server.write_all(&framed).await.unwrap();
5302            })
5303        };
5304
5305        let got = tcp_exchange(&mut client, &query)
5306            .await
5307            .expect("a well-framed answer is read back");
5308        resolver
5309            .await
5310            .expect("the resolver side saw a framed query");
5311
5312        assert_eq!(
5313            got, answer,
5314            "the answer comes back whole — 8000 bytes no datagram here could have carried"
5315        );
5316    }
5317
5318    /// The `TC` bit a truncated UDP answer sets is what sends a stub resolver to TCP (RFC 1035
5319    /// §4.2.1). Setting it *again* on the TCP answer sends that resolver straight back into another
5320    /// retry, so the client's advertised UDP payload size — a property of the datagram it would
5321    /// have been answered in, and one RFC 7766 §8 gives a TCP client no equivalent of — is applied
5322    /// only to a [`ClientTransport::Udp`] client. Same query, same answer, two transports.
5323    #[test]
5324    fn client_udp_limit_is_not_applied_to_a_tcp_client() {
5325        // No EDNS OPT record, so the client's limit is the classic 512 bytes.
5326        let query = build_query(0x310, &["example", "com"], 1, 1);
5327        let mut answer = query.clone();
5328        answer[2] |= 0x80; // make it a response (QR=1)
5329        answer.resize(900, 0xAB); // over 512, under MAX_UPSTREAM_RESPONSE: only the client limit bites
5330
5331        let udp = cap_response(
5332            &query,
5333            answer.clone(),
5334            ClientTransport::Udp,
5335            UpstreamTransport::Udp,
5336        );
5337        assert_ne!(
5338            udp[2] & 0x02,
5339            0,
5340            "a UDP client that advertised 512 bytes is told the 900-byte answer is truncated"
5341        );
5342        assert_eq!(udp.len(), 900, "and the body is left intact either way");
5343
5344        let tcp = cap_response(&query, answer, ClientTransport::Tcp, UpstreamTransport::Udp);
5345        assert_eq!(
5346            tcp[2] & 0x02,
5347            0,
5348            "the same answer over TCP is NOT marked: the client already did the TCP retry"
5349        );
5350        assert_eq!(tcp.len(), 900, "and is relayed whole");
5351    }
5352
5353    /// The relay cap is a different claim from the client's datagram size, and for an answer that
5354    /// crossed the UDP hop it holds on both client transports: when [`MAX_UPSTREAM_RESPONSE`] really
5355    /// did cut the message, `TC` says so. Handing a TCP client a chopped body with `TC` clear would
5356    /// be a malformed-but-"complete" answer.
5357    #[test]
5358    fn a_chopped_answer_is_marked_truncated_on_both_transports() {
5359        let query = build_edns_query(0x311, &["example", "com"], 1, 1, 4096);
5360        let mut big = query.clone();
5361        big[2] |= 0x80;
5362        big.resize(MAX_UPSTREAM_RESPONSE + 500, 0xAB);
5363
5364        let out = cap_response(&query, big, ClientTransport::Tcp, UpstreamTransport::Udp);
5365        assert_eq!(out.len(), MAX_UPSTREAM_RESPONSE, "capped to one datagram");
5366        assert_ne!(
5367            out[2] & 0x02,
5368            0,
5369            "we really did chop the body, so TC is set for a TCP client too"
5370        );
5371    }
5372
5373    #[test]
5374    fn cap_response_sets_tc_when_truncated() {
5375        // An oversize upstream answer is capped to a single datagram AND marked truncated (TC bit)
5376        // so the stub resolver retries over TCP rather than trusting a chopped message. The query
5377        // advertises a big EDNS buffer so only the relay cap can be what fires here.
5378        let query = build_edns_query(0x300, &["example", "com"], 1, 1, 4096);
5379        let mut big = query.clone();
5380        big[2] |= 0x80; // make it a response (QR=1)
5381        big.resize(MAX_UPSTREAM_RESPONSE + 500, 0xAB);
5382
5383        let out = cap_response(&query, big, ClientTransport::Udp, UpstreamTransport::Udp);
5384        assert_eq!(out.len(), MAX_UPSTREAM_RESPONSE, "capped to one datagram");
5385        assert_ne!(out[2] & 0x02, 0, "TC bit set on truncation");
5386    }
5387
5388    #[test]
5389    fn cap_response_leaves_small_response_untouched() {
5390        // A response that fits both bounds is returned verbatim with no TC bit forced on.
5391        let query = build_query(0x301, &["example", "com"], 1, 1);
5392        let mut small = query.clone();
5393        small[2] |= 0x80;
5394        let before = small.clone();
5395
5396        let out = cap_response(&query, small, ClientTransport::Udp, UpstreamTransport::Udp);
5397        assert_eq!(out, before, "small response unchanged");
5398        assert_eq!(out[2] & 0x02, 0, "TC bit not set when no truncation");
5399    }
5400
5401    #[test]
5402    fn cap_is_a_relay_bound_not_the_read_bound() {
5403        // `forward_query` reads with `recv_from_bytes`, which issues `Recv { max_len: None }`, so
5404        // the netstack has already copied the whole datagram out before `cap_response` runs: the
5405        // cap bounds what we relay, not what we read or allocate. What bounds the read is the
5406        // netstack UDP socket's receive ring (`udp_buffer_size`, which `ts_runtime` leaves at the
5407        // `netcore` default) -- smoltcp drops a datagram larger than that ring at enqueue instead
5408        // of delivering it, and hands us everything up to and including the ring whole. The ring
5409        // being *wider* than the cap is what shows the two are different bounds: the read can put
5410        // more bytes in front of `cap_response` than the cap will relay.
5411        let ring = netstack::netcore::Config::default().udp_buffer_size;
5412        assert!(
5413            ring > MAX_UPSTREAM_RESPONSE,
5414            "the netstack udp receive ring ({ring}) no longer exceeds the relay cap \
5415             ({MAX_UPSTREAM_RESPONSE}): the cap would then be unreachable through this socket, and \
5416             the doc describing it as a relay bound the read can overrun is wrong"
5417        );
5418
5419        // The largest answer the cap passes is relayed byte-for-byte. Ask with an EDNS buffer that
5420        // covers the whole datagram, so the client-limit check (the other half of `cap_response`)
5421        // is not what we are measuring.
5422        let query = build_edns_query(0x302, &["example", "com"], 1, 1, 4096);
5423        let mut largest = query.clone();
5424        largest[2] |= 0x80; // QR=1
5425        largest.resize(MAX_UPSTREAM_RESPONSE, 0xAB);
5426        let before = largest.clone();
5427
5428        let out = cap_response(
5429            &query,
5430            largest,
5431            ClientTransport::Udp,
5432            UpstreamTransport::Udp,
5433        );
5434        assert_eq!(out, before, "an answer at the cap must be relayed verbatim");
5435        assert_eq!(
5436            out[2] & 0x02,
5437            0,
5438            "TC must not be set on a datagram that was never chopped"
5439        );
5440    }
5441
5442    #[test]
5443    fn full_ring_datagram_is_chopped_and_marked_truncated() {
5444        // Upstream's bound is `const maxResponseBytes = 4095` (net/dns/resolver/tsdns.go @
5445        // 9ea7cba44591e0cd840c6c94d23274dd222059bf). `sendUDP` reads into `maxResponseBytes+1`
5446        // bytes exactly so a 4096-byte answer is detectable as "did not fit", then cuts it to 4095
5447        // and sets TC. Here the netstack's 4096-byte receive ring plays the part of Go's `+1`: a
5448        // full-ring datagram is the one deliverable size the cap does not pass, and it must come
5449        // back with the same shape a Go forwarder would have produced. With the cap at 4096 this
5450        // datagram was relayed whole with TC clear, while a Go client on the same tailnet answering
5451        // the same query returned 4095 bytes marked truncated.
5452        let ring = netstack::netcore::Config::default().udp_buffer_size;
5453        let query = build_edns_query(0x303, &["example", "com"], 1, 1, 4096);
5454        let mut full_ring = query.clone();
5455        full_ring[2] |= 0x80; // QR=1
5456        full_ring.resize(ring, 0xAB);
5457
5458        let out = cap_response(
5459            &query,
5460            full_ring,
5461            ClientTransport::Udp,
5462            UpstreamTransport::Udp,
5463        );
5464        assert_eq!(
5465            out.len(),
5466            4095,
5467            "a full-ring answer must be cut to upstream's maxResponseBytes"
5468        );
5469        assert_ne!(out[2] & 0x02, 0, "TC bit set on the chopped answer");
5470    }
5471
5472    #[test]
5473    fn forwarded_reply_over_512_sets_tc_for_a_plain_query() {
5474        // A query with no EDNS OPT record is limited to 512 bytes (RFC 1035), so a 900-byte
5475        // forwarded reply -- well under the 4095 relay cap, and therefore relayed with TC clear
5476        // before this check existed -- must come back marked truncated, body intact.
5477        let query = build_query(0x400, &["example", "com"], 1, 1);
5478        let mut reply = query.clone();
5479        reply[2] |= 0x80; // QR=1
5480        reply.resize(900, 0xAB);
5481
5482        let out = cap_response(
5483            &query,
5484            reply.clone(),
5485            ClientTransport::Udp,
5486            UpstreamTransport::Udp,
5487        );
5488
5489        assert_ne!(
5490            out[2] & 0x02,
5491            0,
5492            "a 900-byte reply to a non-EDNS query must have TC set"
5493        );
5494        assert_eq!(out.len(), 900, "the body is left intact, not chopped");
5495        assert_eq!(
5496            out[3..],
5497            reply[3..],
5498            "only the flags byte carrying TC may differ"
5499        );
5500    }
5501
5502    #[test]
5503    fn forwarded_reply_under_advertised_edns_size_leaves_tc_clear() {
5504        // The same 900-byte reply, but the client advertised a 4096-byte EDNS buffer: it fits, so
5505        // TC must stay clear and the datagram must be relayed byte-for-byte.
5506        let query = build_edns_query(0x401, &["example", "com"], 1, 1, 4096);
5507        let mut reply = query.clone();
5508        reply[2] |= 0x80; // QR=1
5509        reply.resize(900, 0xAB);
5510        let before = reply.clone();
5511
5512        let out = cap_response(&query, reply, ClientTransport::Udp, UpstreamTransport::Udp);
5513
5514        assert_eq!(
5515            out, before,
5516            "a reply within the advertised buffer is verbatim"
5517        );
5518        assert_eq!(out[2] & 0x02, 0, "TC must stay clear");
5519    }
5520
5521    /// Go's `findOPTRecord` accepts an OPT record only in the final 11 bytes of the message, with a
5522    /// root NAME, EDNS version 0 and `RDLEN == 0`; anything else is "no EDNS", i.e. the 512-byte
5523    /// RFC 1035 limit. Every rejection below is a case where a laxer reader would honour a large
5524    /// advertised buffer and leave `TC` clear on an answer a Go node marks truncated.
5525    #[test]
5526    fn client_udp_limit_reads_the_opt_record() {
5527        // No OPT record => the RFC 1035 512-byte limit.
5528        let plain = build_query(0x402, &["example", "com"], 1, 1);
5529        assert_eq!(client_udp_limit(&plain), NO_EDNS_UDP_LIMIT);
5530
5531        // An OPT record's CLASS field carries the advertised size.
5532        let edns = build_edns_query(0x403, &["example", "com"], 1, 1, 1232);
5533        assert_eq!(client_udp_limit(&edns), 1232);
5534
5535        // A value below 512 is taken verbatim. RFC 6891 6.2.3 would floor it at 512, but Go does
5536        // not (`maxSize = int(ednsSize)`), so a Rust node that did would leave `TC` clear where a
5537        // Go node on the same tailnet sets it.
5538        let tiny = build_edns_query(0x404, &["example", "com"], 1, 1, 64);
5539        assert_eq!(client_udp_limit(&tiny), 64);
5540
5541        // An OPT record that is not the last record in the message is not read at all: upstream
5542        // only ever looks at the final 11 bytes.
5543        let mut trailing_rr = build_edns_query(0x405, &["example", "com"], 1, 1, 2048);
5544        // A 1-byte-RDATA TXT (type 16) record for the root name, appended after the OPT.
5545        trailing_rr.extend_from_slice(&[0, 0, 16, 0, 1, 0, 0, 0, 0, 0, 1, 0]);
5546        trailing_rr[11] = 2; // ARCOUNT = 2
5547        assert_eq!(client_udp_limit(&trailing_rr), NO_EDNS_UDP_LIMIT);
5548
5549        // An OPT record carrying options — a DNS cookie, EDNS Client Subnet — has RDLEN != 0 and is
5550        // rejected. This is the common case, not a corner: stub resolvers send cookies routinely.
5551        let cookie =
5552            build_edns_query_with_option(0x406, &["example", "com"], 1, 1, 4096, 10, &[0; 8]);
5553        assert_eq!(client_udp_limit(&cookie), NO_EDNS_UDP_LIMIT);
5554
5555        // An unknown EDNS version is left alone rather than guessed at.
5556        let mut future_version = build_edns_query(0x407, &["example", "com"], 1, 1, 4096);
5557        let ttl_at = future_version.len() - 6; // TTL = extended RCODE (1) | VERSION (1) | flags (2)
5558        future_version[ttl_at + 1] = 1; // EDNS version 1
5559        assert_eq!(client_udp_limit(&future_version), NO_EDNS_UDP_LIMIT);
5560
5561        // A non-root OPT NAME is rejected.
5562        let mut named = build_edns_query(0x408, &["example", "com"], 1, 1, 4096);
5563        let name_at = named.len() - 11;
5564        named[name_at] = 0xC0; // a compression pointer where the root label must be
5565        assert_eq!(client_udp_limit(&named), NO_EDNS_UDP_LIMIT);
5566
5567        // ARCOUNT == 0 means there is no additional section to hold an OPT, whatever the trailing
5568        // bytes happen to look like.
5569        let mut no_ar = build_edns_query(0x409, &["example", "com"], 1, 1, 4096);
5570        no_ar[11] = 0;
5571        assert_eq!(client_udp_limit(&no_ar), NO_EDNS_UDP_LIMIT);
5572
5573        // A truncated message falls back to the conservative limit, never a larger one.
5574        let mut chopped = build_edns_query(0x40A, &["example", "com"], 1, 1, 4096);
5575        chopped.truncate(chopped.len() - 8);
5576        assert_eq!(client_udp_limit(&chopped), NO_EDNS_UDP_LIMIT);
5577    }
5578
5579    /// The whole point of the narrow OPT reader, end to end: a stub resolver that advertises 4096
5580    /// **and** sends a DNS cookie is capped at 512, so the 900-byte forwarded reply comes back with
5581    /// `TC` set. A reader that walked the additional section properly would honour the 4096 and
5582    /// leave `TC` clear — which is the answer no Go node on the tailnet would have produced.
5583    #[test]
5584    fn an_opt_record_carrying_options_is_not_honoured() {
5585        let query =
5586            build_edns_query_with_option(0x40B, &["example", "com"], 1, 1, 4096, 10, &[0; 8]);
5587        let mut reply = query.clone();
5588        reply[2] |= 0x80; // QR=1
5589        reply.resize(900, 0xAB);
5590
5591        let out = cap_response(&query, reply, ClientTransport::Udp, UpstreamTransport::Udp);
5592        assert_ne!(
5593            out[2] & 0x02,
5594            0,
5595            "an OPT record with options is no EDNS at all upstream: the 512-byte limit applies"
5596        );
5597        assert_eq!(out.len(), 900, "the body is left intact, not chopped");
5598    }
5599
5600    /// An advertised size below 512 is honoured as-is. Go floors nothing: `maxSize = int(ednsSize)`
5601    /// whenever an OPT record is present, and only a request with no OPT record falls back to 512.
5602    #[test]
5603    fn an_advertised_size_below_512_is_not_floored() {
5604        let query = build_edns_query(0x40C, &["example", "com"], 1, 1, 200);
5605        let mut reply = query.clone();
5606        reply[2] |= 0x80; // QR=1
5607        reply.resize(300, 0xAB);
5608
5609        let out = cap_response(&query, reply, ClientTransport::Udp, UpstreamTransport::Udp);
5610        assert_ne!(
5611            out[2] & 0x02,
5612            0,
5613            "300 bytes overflows the 200 the client asked for, so TC is set"
5614        );
5615        assert_eq!(out.len(), 300, "the body is left intact, not chopped");
5616    }
5617
5618    /// Upstream runs the size check on answers the resolver builds itself, not only on forwarded
5619    /// ones (`Resolver.Query` calls `checkResponseSizeAndSetTC` right after `respond` succeeds). An
5620    /// authoritative answer is capped at 512 bytes, which says nothing about a client that
5621    /// advertised less than that.
5622    #[test]
5623    fn an_authoritative_answer_over_the_advertised_size_is_marked() {
5624        let view = view_with_peer();
5625        let buf = build_edns_query(0x40D, &["host", "user", "ts", "net"], 1, 1, 20);
5626
5627        let resp = answer(&view, &buf).expect("answers");
5628        assert!(
5629            resp.len() > 20,
5630            "the fixture only works if the answer overflows the advertised 20 bytes"
5631        );
5632
5633        let marked = check_response_size_and_set_tc(&buf, resp.clone(), ClientTransport::Udp);
5634        assert_ne!(
5635            marked[2] & 0x02,
5636            0,
5637            "an answer we composed ourselves can still overflow a small advertised buffer"
5638        );
5639        assert_eq!(marked.len(), resp.len(), "the body is left intact");
5640        assert_eq!(
5641            marked[3..],
5642            resp[3..],
5643            "only the flags byte carrying TC may differ"
5644        );
5645    }
5646
5647    #[test]
5648    fn response_matches_query_rejects_mismatched_question() {
5649        // id + QR match but the echoed question differs (different QNAME) => rejected. This guards
5650        // against an off-path injector that guesses the id but answers a different question.
5651        let query = build_query(0x1234, &["a", "com"], 1, 1);
5652
5653        let mut wrong_question = build_query(0x1234, &["b", "com"], 1, 1);
5654        wrong_question[2] |= 0x80; // QR=1, same id
5655        assert!(
5656            !response_matches_query(&query, &wrong_question),
5657            "different QNAME must be rejected"
5658        );
5659
5660        // A different QTYPE with the same name is also rejected.
5661        let mut wrong_qtype = build_query(0x1234, &["a", "com"], 28, 1);
5662        wrong_qtype[2] |= 0x80;
5663        assert!(
5664            !response_matches_query(&query, &wrong_qtype),
5665            "different QTYPE must be rejected"
5666        );
5667
5668        // The exact echoed question with QR=1 is accepted.
5669        let mut good = query.clone();
5670        good[2] |= 0x80;
5671        assert!(
5672            response_matches_query(&query, &good),
5673            "matching question accepted"
5674        );
5675    }
5676
5677    #[test]
5678    fn suffix_matches_handles_boundaries_and_empty() {
5679        // Exact and label-boundary matches.
5680        assert!(suffix_matches("corp", "corp"));
5681        assert!(suffix_matches("a.corp", "corp"));
5682        assert!(suffix_matches("a.b.corp", "corp"));
5683        // Not a label boundary.
5684        assert!(!suffix_matches("acorp", "corp"));
5685        // Empty suffix never matches (defense-in-depth against `ends_with("")`).
5686        assert!(!suffix_matches("anything.example", ""));
5687        assert!(!suffix_matches("", ""));
5688    }
5689
5690    #[test]
5691    fn empty_search_domain_does_not_capture_everything() {
5692        // Defense-in-depth: an empty search domain must NOT make every name look like a tailnet
5693        // name (which would fail-close legitimate recursive queries / mis-route). With an empty
5694        // suffix present alongside a real resolver, an off-tailnet name still forwards.
5695        let mut view = view_with_routes(
5696            std::collections::BTreeMap::new(),
5697            vec![udp("8.8.8.8:53")],
5698            vec![],
5699        );
5700        view.cfg.search_domains = vec![String::new()];
5701        let buf = build_query(0x400, &["example", "com"], 1, 1);
5702
5703        match decide(&view, &buf).expect("decides") {
5704            Decision::Forward { upstreams, .. } => {
5705                assert_eq!(upstreams, vec!["8.8.8.8:53".parse().unwrap()]);
5706            }
5707            Decision::Reply(_) => {
5708                panic!("empty search domain must not treat every name as tailnet")
5709            }
5710        }
5711    }
5712
5713    #[test]
5714    fn empty_route_suffix_does_not_capture_everything() {
5715        // Defense-in-depth: an empty route suffix must not match every name (which would route all
5716        // queries to that route's upstreams). With an empty-suffix route present, an unrelated name
5717        // still falls through to the global resolver.
5718        let mut routes = std::collections::BTreeMap::new();
5719        routes.insert(String::new(), vec![udp("10.9.9.9:53")]);
5720        let view = view_with_routes(routes, vec![udp("8.8.8.8:53")], vec![]);
5721        let buf = build_query(0x401, &["example", "com"], 1, 1);
5722
5723        match decide(&view, &buf).expect("decides") {
5724            Decision::Forward { upstreams, .. } => {
5725                assert_eq!(
5726                    upstreams,
5727                    vec!["8.8.8.8:53".parse().unwrap()],
5728                    "empty route suffix must not capture; falls through to global"
5729                );
5730            }
5731            Decision::Reply(_) => panic!("expected forward to global resolver"),
5732        }
5733    }
5734
5735    fn udp_exit(addr: &str) -> DnsResolver {
5736        DnsResolver {
5737            transport: ts_control::ResolverTransport::Udp(addr.parse().unwrap()),
5738            use_with_exit_node: true,
5739        }
5740    }
5741
5742    #[test]
5743    fn recursive_forward_is_flagged_route_forward_is_not() {
5744        // A recursive (global/fallback) forward sets `recursive = true` (eligible for DoH
5745        // delegation); a deliberately-configured split-DNS route sets `recursive = false`.
5746        let mut routes = std::collections::BTreeMap::new();
5747        routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
5748        let view = view_with_routes(routes, vec![udp("8.8.8.8:53")], vec![]);
5749
5750        let routed = build_query(0x500, &["api", "corp", "example"], 1, 1);
5751        match decide(&view, &routed).expect("decides") {
5752            Decision::Forward { recursive, .. } => {
5753                assert!(!recursive, "split-DNS route is not a recursive forward")
5754            }
5755            Decision::Reply(_) => panic!("expected route forward"),
5756        }
5757
5758        let global = build_query(0x501, &["example", "com"], 1, 1);
5759        match decide(&view, &global).expect("decides") {
5760            Decision::Forward { recursive, .. } => {
5761                assert!(recursive, "unrouted name is a recursive forward")
5762            }
5763            Decision::Reply(_) => panic!("expected recursive forward"),
5764        }
5765    }
5766
5767    #[test]
5768    fn recursive_plan_keeps_udp_without_exit_node() {
5769        // No active exit node: a recursive forward stays on its default UDP upstreams.
5770        let view = view_with_routes(
5771            std::collections::BTreeMap::new(),
5772            vec![udp("8.8.8.8:53")],
5773            vec![],
5774        );
5775        let default = vec!["8.8.8.8:53".parse().unwrap()];
5776        assert_eq!(
5777            recursive_plan(&view, default.clone()),
5778            RecursivePlan::Udp(default)
5779        );
5780    }
5781
5782    #[test]
5783    fn recursive_plan_delegates_to_doh_with_exit_node() {
5784        // Exit node active, no kept-local resolvers: recursive queries delegate to the exit node's
5785        // DoH endpoint so resolution egresses from the exit node, not this host.
5786        let mut view = view_with_routes(
5787            std::collections::BTreeMap::new(),
5788            vec![udp("8.8.8.8:53")],
5789            vec![],
5790        );
5791        let doh: SocketAddr = "100.64.0.5:8080".parse().unwrap();
5792        view.exit_doh = Some(doh);
5793        assert_eq!(
5794            recursive_plan(&view, vec!["8.8.8.8:53".parse().unwrap()]),
5795            RecursivePlan::Doh(doh)
5796        );
5797    }
5798
5799    #[test]
5800    fn recursive_plan_keeps_use_with_exit_node_resolvers_local() {
5801        // Even with an exit node active, resolvers flagged `use_with_exit_node` stay local (Go keeps
5802        // UseWithExitNode resolvers). The plan forwards to those over UDP, never delegating to DoH.
5803        let mut view = view_with_routes(
5804            std::collections::BTreeMap::new(),
5805            vec![udp_exit("10.0.0.53:53"), udp("8.8.8.8:53")],
5806            vec![],
5807        );
5808        view.exit_doh = Some("100.64.0.5:8080".parse().unwrap());
5809        // The default upstreams the caller computed are irrelevant when kept-local resolvers exist;
5810        // the plan must use the kept-local ones.
5811        assert_eq!(
5812            recursive_plan(&view, vec!["8.8.8.8:53".parse().unwrap()]),
5813            RecursivePlan::Udp(vec!["10.0.0.53:53".parse().unwrap()])
5814        );
5815    }
5816
5817    // --- SOA on authoritative negative answers (RFC 2308) -----------------------------------
5818
5819    /// Read an uncompressed name at `off`, returning it dotted and the offset just past it.
5820    fn read_name(resp: &[u8], mut off: usize) -> (String, usize) {
5821        let mut labels: Vec<String> = Vec::new();
5822        loop {
5823            let len = resp[off] as usize;
5824            assert_eq!(len & 0xC0, 0, "no compression pointer expected here");
5825            off += 1;
5826            if len == 0 {
5827                break;
5828            }
5829            labels.push(String::from_utf8(resp[off..off + len].to_vec()).expect("ascii label"));
5830            off += len;
5831        }
5832        (labels.join("."), off)
5833    }
5834
5835    /// The number of records in a response's authority section (NSCOUNT).
5836    fn nscount(resp: &[u8]) -> u16 {
5837        u16::from_be_bytes([resp[8], resp[9]])
5838    }
5839
5840    /// Walk an answer-less response to its authority section and read the SOA there, returning
5841    /// `(zone, record TTL, SERIAL, MINIMUM)`. `None` when the authority section is empty.
5842    ///
5843    /// Also asserts the record's shape as it goes: TYPE=SOA, CLASS=IN, and MNAME/RNAME both equal
5844    /// the owner name (the placeholders Go writes).
5845    fn parse_soa(resp: &[u8]) -> Option<(String, u32, u32, u32)> {
5846        let (.., ancount) = parse_header(resp);
5847        assert_eq!(ancount, 0, "parse_soa only walks answer-less responses");
5848        if nscount(resp) == 0 {
5849            return None;
5850        }
5851        assert_eq!(nscount(resp), 1, "at most one SOA");
5852
5853        // Question: QNAME then QTYPE + QCLASS.
5854        let (_, off) = read_name(resp, 12);
5855        // Authority record: NAME, TYPE, CLASS, TTL, RDLENGTH, RDATA.
5856        let (zone, off) = read_name(resp, off + 4);
5857        let u16_at = |at: usize| u16::from_be_bytes([resp[at], resp[at + 1]]);
5858        let u32_at = |at: usize| u32::from_be_bytes(resp[at..at + 4].try_into().unwrap());
5859        assert_eq!(u16_at(off), 6, "TYPE = SOA");
5860        assert_eq!(u16_at(off + 2), 1, "CLASS = IN");
5861        let ttl = u32_at(off + 4);
5862        let rdlength = u16_at(off + 8) as usize;
5863
5864        // RDATA: MNAME, RNAME, SERIAL, REFRESH, RETRY, EXPIRE, MINIMUM.
5865        let rdata_start = off + 10;
5866        let (mname, off) = read_name(resp, rdata_start);
5867        let (rname, off) = read_name(resp, off);
5868        assert_eq!(mname, zone, "MNAME is the zone (placeholder)");
5869        assert_eq!(rname, zone, "RNAME is the zone (placeholder)");
5870        let serial = u32_at(off);
5871        let minimum = u32_at(off + 16);
5872        assert_eq!(
5873            off + 20 - rdata_start,
5874            rdlength,
5875            "RDLENGTH covers exactly the SOA fields"
5876        );
5877        assert_eq!(resp.len(), off + 20, "the SOA is the last record");
5878        Some((zone, ttl, serial, minimum))
5879    }
5880
5881    /// Roughly-now, for asserting the SOA SERIAL is a unix timestamp rather than a constant.
5882    fn now_unix() -> u32 {
5883        std::time::SystemTime::now()
5884            .duration_since(std::time::UNIX_EPOCH)
5885            .expect("clock after the epoch")
5886            .as_secs() as u32
5887    }
5888
5889    /// An NXDOMAIN for a name under a tailnet search domain is authoritative, so it carries that
5890    /// search domain's SOA with the 10-second negative TTL. Without it a downstream cache picks its
5891    /// own (much longer) negative lifetime and a node renamed to that name stays unresolvable.
5892    #[test]
5893    fn nxdomain_for_tailnet_name_carries_the_search_domain_soa() {
5894        let view = view_with_peer();
5895        let buf = build_query(0x1111, &["nope", "user", "ts", "net"], 1, 1);
5896
5897        let resp = answer(&view, &buf).expect("answers");
5898        let (_, rcode, ancount) = parse_header(&resp);
5899        assert_eq!(rcode, 3, "NXDOMAIN");
5900        assert_eq!(ancount, 0);
5901
5902        let (zone, ttl, serial, minimum) =
5903            parse_soa(&resp).expect("an SOA in the authority section");
5904        assert_eq!(zone, "user.ts.net", "the search domain containing the name");
5905        assert_eq!(ttl, 10, "negative TTL");
5906        assert_eq!(minimum, 10, "MINIMUM also bounds negative caching");
5907        // The serial is the response time in unix seconds, not a fixed placeholder.
5908        assert!(
5909            serial.abs_diff(now_unix()) < 60,
5910            "SERIAL should be about now, got {serial}"
5911        );
5912    }
5913
5914    /// A NODATA — the name exists but we hold no address of the queried family, which is what an
5915    /// AAAA query for a peer becomes with the IPv6 gate off — is negative too, and takes the SOA.
5916    #[test]
5917    fn nodata_aaaa_for_known_peer_carries_the_soa() {
5918        let view = view_with_peer();
5919        assert!(!view.enable_ipv6, "default gate is off");
5920        let buf = build_query(0x2222, &["host", "user", "ts", "net"], 28, 1);
5921
5922        let resp = answer(&view, &buf).expect("answers");
5923        let (_, rcode, ancount) = parse_header(&resp);
5924        assert_eq!(rcode, 0, "NoError (NODATA)");
5925        assert_eq!(ancount, 0);
5926        let (zone, ttl, _, minimum) = parse_soa(&resp).expect("an SOA in the authority section");
5927        assert_eq!(zone, "user.ts.net");
5928        assert_eq!((ttl, minimum), (10, 10));
5929    }
5930
5931    /// A reverse query for an unmatched IP in the tailnet CGNAT range is authoritatively absent, so
5932    /// it carries the SOA of the reverse zone that covers it — the same per-/16 `in-addr.arpa`
5933    /// chunk real tailscaled advertises, not the search domain.
5934    #[test]
5935    fn cgnat_reverse_miss_carries_the_reverse_zone_soa() {
5936        let view = view_with_peer();
5937        // Reverse name for an unclaimed 100.64.0.0/10 address, least-significant octet first.
5938        let buf = build_query(0x3333, &["9", "0", "64", "100", "in-addr", "arpa"], 12, 1);
5939
5940        let resp = answer(&view, &buf).expect("answers");
5941        let (_, rcode, ancount) = parse_header(&resp);
5942        assert_eq!(rcode, 3, "NXDOMAIN");
5943        assert_eq!(ancount, 0);
5944        let (zone, ttl, _, minimum) = parse_soa(&resp).expect("an SOA in the authority section");
5945        assert_eq!(zone, "64.100.in-addr.arpa", "the CGNAT reverse zone");
5946        assert_eq!((ttl, minimum), (10, 10));
5947    }
5948
5949    /// The exotic-qtype path re-applies the CGNAT reverse guard, and its NXDOMAIN is just as
5950    /// authoritative — so it carries the same reverse-zone SOA the PTR arm does.
5951    #[test]
5952    fn exotic_qtype_cgnat_reverse_nxdomain_carries_the_soa() {
5953        let view = view_with_peer();
5954        // TXT (16) for a CGNAT reverse name.
5955        let buf = build_query(0x4444, &["9", "0", "64", "100", "in-addr", "arpa"], 16, 1);
5956
5957        let resp = answer(&view, &buf).expect("answers");
5958        assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
5959        let (zone, ..) = parse_soa(&resp).expect("an SOA in the authority section");
5960        assert_eq!(zone, "64.100.in-addr.arpa");
5961    }
5962
5963    /// A negative split-DNS route (a route with no resolvers) is Go's `localDomains` verbatim: the
5964    /// NXDOMAIN it produces is authoritative and names the route's own suffix as its zone.
5965    #[test]
5966    fn negative_route_nxdomain_carries_the_route_zone_soa() {
5967        let mut routes = std::collections::BTreeMap::new();
5968        routes.insert("corp.example".to_string(), vec![]);
5969        let view = view_with_routes(routes, vec![], vec![]);
5970        let buf = build_query(0x5555, &["intranet", "corp", "example"], 1, 1);
5971
5972        let resp = answer(&view, &buf).expect("answers");
5973        assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
5974        let (zone, ttl, _, minimum) = parse_soa(&resp).expect("an SOA in the authority section");
5975        assert_eq!(zone, "corp.example");
5976        assert_eq!((ttl, minimum), (10, 10));
5977    }
5978
5979    /// Answers we are NOT authoritative for carry no SOA: a SERVFAIL is a soft failure with nothing
5980    /// to cache, and an `ip6.arpa` NXDOMAIN is this fork's blanket anti-leak refusal, not a claim to
5981    /// serve the IPv6 reverse tree.
5982    #[test]
5983    fn non_authoritative_negative_answers_carry_no_soa() {
5984        let view = view_with_peer();
5985
5986        // Off-tailnet name, no upstream configured => SERVFAIL.
5987        let servfail =
5988            answer(&view, &build_query(0x6, &["example", "com"], 1, 1)).expect("answers");
5989        assert_eq!(parse_header(&servfail).1, 2, "ServFail");
5990        assert_eq!(nscount(&servfail), 0, "SERVFAIL carries no SOA");
5991
5992        // An ip6.arpa reverse name. The exact nibble labels do not matter to the guard.
5993        let mut labels: Vec<&str> = vec!["1"; 32];
5994        labels.push("ip6");
5995        labels.push("arpa");
5996        let ip6 = answer(&view, &build_query(0x7, &labels, 12, 1)).expect("answers");
5997        assert_eq!(parse_header(&ip6).1, 3, "NXDOMAIN");
5998        assert_eq!(nscount(&ip6), 0, "ip6.arpa NXDOMAIN carries no SOA");
5999
6000        // MagicDNS off => REFUSED, which asserts nothing about the name.
6001        let mut off = view_with_peer();
6002        off.cfg.magic_dns = false;
6003        let refused = answer(
6004            &off,
6005            &build_query(0x8, &["host", "user", "ts", "net"], 1, 1),
6006        )
6007        .expect("answers");
6008        assert_eq!(parse_header(&refused).1, 5, "Refused");
6009        assert_eq!(nscount(&refused), 0, "REFUSED carries no SOA");
6010    }
6011
6012    /// A NODATA for a type we simply do not serve on a name we do (TXT on a tailnet name) carries
6013    /// no SOA: Go sets `SOAZone` on a no-data answer only for an A/AAAA/ALL question.
6014    #[test]
6015    fn nodata_for_an_unserved_qtype_carries_no_soa() {
6016        let view = view_with_peer();
6017        let resp = answer(
6018            &view,
6019            &build_query(0x9, &["host", "user", "ts", "net"], 16, 1),
6020        )
6021        .expect("answers");
6022        let (_, rcode, ancount) = parse_header(&resp);
6023        assert_eq!((rcode, ancount), (0, 0), "NODATA");
6024        assert_eq!(nscount(&resp), 0);
6025    }
6026
6027    /// A positive answer has an empty authority section and a 5-second TTL. The short TTL is the
6028    /// positive half of the same argument: the netmap is local and in-memory, so a re-query is
6029    /// nearly free, while a downstream cache would otherwise hide a node rename for the full TTL.
6030    #[test]
6031    fn positive_answer_has_ttl_5_and_no_authority_section() {
6032        let view = view_with_peer();
6033        let resp = answer(
6034            &view,
6035            &build_query(0xA, &["host", "user", "ts", "net"], 1, 1),
6036        )
6037        .expect("answers");
6038        let (_, rcode, ancount) = parse_header(&resp);
6039        assert_eq!((rcode, ancount), (0, 1), "one A record");
6040        assert_eq!(nscount(&resp), 0, "a positive answer claims no zone");
6041        // The single A record's tail is TTL, RDLENGTH, RDATA.
6042        let ttl_at = resp.len() - 10;
6043        let ttl = u32::from_be_bytes(resp[ttl_at..ttl_at + 4].try_into().unwrap());
6044        assert_eq!(ttl, 5, "positive TTL");
6045    }
6046
6047    /// An authoritative negative answer with its SOA attached must still fit the classic 512-byte
6048    /// UDP limit, so the client-limit check leaves TC clear on it for a client that advertised no
6049    /// EDNS buffer. (A client that advertises *less* than 512 is a different case and is marked —
6050    /// see `an_authoritative_answer_over_the_advertised_size_is_marked`.)
6051    #[test]
6052    fn nxdomain_with_soa_stays_within_the_client_udp_limit() {
6053        let view = view_with_peer();
6054        let long = "a".repeat(63);
6055        let buf = build_query(0xB, &[&long, "user", "ts", "net"], 1, 1);
6056
6057        let resp = answer(&view, &buf).expect("answers");
6058        assert_eq!(nscount(&resp), 1, "the SOA fits beside this question");
6059        assert!(resp.len() <= 512, "still one classic UDP datagram");
6060
6061        let marked = check_response_size_and_set_tc(&buf, resp.clone(), ClientTransport::Udp);
6062        assert_eq!(marked, resp, "nothing to mark: an authoritative reply fits");
6063        assert_eq!(
6064            u16::from_be_bytes([marked[2], marked[3]]) & 0x0200,
6065            0,
6066            "TC must stay clear"
6067        );
6068    }
6069
6070    /// When the zone is so long that its SOA no longer fits under the 512-byte cap, the SOA is
6071    /// dropped rather than the answer being truncated: the NXDOMAIN goes back complete, with an
6072    /// empty authority section, TC clear, and still within a client's UDP limit. Losing the SOA
6073    /// only means a resolver falls back to its own negative-cache policy.
6074    #[test]
6075    fn an_soa_that_will_not_fit_is_dropped_and_the_nxdomain_still_answers() {
6076        let long = "a".repeat(63);
6077        let zone = [long.as_str(), long.as_str(), long.as_str()].join(".");
6078        let mut view = view_with_peer();
6079        view.cfg.search_domains = vec![zone.clone()];
6080
6081        let buf = build_query(0xC, &["x", &long, &long, &long], 1, 1);
6082        let resp = answer(&view, &buf).expect("answers");
6083
6084        assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
6085        assert_eq!(nscount(&resp), 0, "the SOA did not fit and was dropped");
6086        assert!(resp.len() <= 512, "response stays within the UDP limit");
6087        let marked = check_response_size_and_set_tc(&buf, resp.clone(), ClientTransport::Udp);
6088        assert_eq!(
6089            u16::from_be_bytes([marked[2], marked[3]]) & 0x0200,
6090            0,
6091            "a dropped SOA must not set TC: the fork cannot serve the TCP retry it would ask for"
6092        );
6093    }
6094
6095    /// The zone is the *longest* authoritative suffix containing the name, so a name under a
6096    /// sub-zone gets the sub-zone's SOA rather than the shorter search domain's.
6097    #[test]
6098    fn the_longest_authoritative_zone_wins() {
6099        let mut routes = std::collections::BTreeMap::new();
6100        routes.insert("sub.user.ts.net".to_string(), vec![]);
6101        let mut view = view_with_routes(routes, vec![], vec![]);
6102        view.cfg.search_domains = vec!["user.ts.net".to_string()];
6103
6104        let buf = build_query(0xD, &["nope", "sub", "user", "ts", "net"], 1, 1);
6105        let resp = answer(&view, &buf).expect("answers");
6106        assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
6107        let (zone, ..) = parse_soa(&resp).expect("an SOA in the authority section");
6108        assert_eq!(zone, "sub.user.ts.net");
6109    }
6110
6111    /// A name we resolved only by search-domain qualification (a short name like `host`) is not
6112    /// itself inside a zone we serve, so its negative answer names no zone — matching Go, whose
6113    /// `authoritativeZoneFor` is given the query name as asked.
6114    #[test]
6115    fn a_short_name_outside_every_zone_gets_no_soa() {
6116        let mut view = view_with_peer();
6117        view.enable_ipv6 = false;
6118        // `host` resolves to the peer via search-domain qualification, and with IPv6 off the AAAA
6119        // is a NODATA — but `host` sits under no zone we serve.
6120        let resp = answer(&view, &build_query(0xE, &["host"], 28, 1)).expect("answers");
6121        let (_, rcode, ancount) = parse_header(&resp);
6122        assert_eq!((rcode, ancount), (0, 0), "NODATA");
6123        assert_eq!(nscount(&resp), 0, "no zone contains a single-label name");
6124    }
6125}