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