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