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