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.
1337pub struct MagicDnsActor {
1338    /// Keeps the socket-serving task alive for the lifetime of the actor.
1339    _joinset: JoinSet<()>,
1340    /// The latest view, shared with the answer loop.
1341    view_tx: watch::Sender<Arc<DnsView>>,
1342    /// The latest control-derived packet filter, shared with the peerAPI server task for the DoH
1343    /// source gate ([`crate::peerapi_doh::dns_source_allowed`]). Kept out of [`DnsView`] because it
1344    /// is not DNS data and updates on its own cadence (a netmap can carry a new filter without a new
1345    /// DNS config, and the other way round).
1346    filter_tx: watch::Sender<Option<crate::packetfilter::PacketFilterState>>,
1347    /// The runtime [`Env`], retained so each view rebuild (the `StateUpdate` / `PeerState` handlers)
1348    /// can re-read the live [`Env::accept_dns`] cell. Unlike `enable_ipv6` (snapshotted once at
1349    /// spawn), `accept_dns` is runtime-settable via `Device::set_accept_dns`, so it must be read at
1350    /// rebuild time — not captured once — for a toggle to reach the served view.
1351    env: Env,
1352    /// The overlay channel, retained so the [`Query`] handler can run a query through the same
1353    /// forward path the serve loop uses ([`forward_query`] / [`forward_doh`], both binding
1354    /// `0.0.0.0:0` on this channel — never a host socket).
1355    channel: Channel,
1356}
1357
1358/// A programmatic DNS query routed through the live MagicDNS responder (the `100.100.100.100` path),
1359/// for [`Device::query_dns`](crate::Device::query_dns). The handler synthesizes a query packet and
1360/// drives it through the exact same [`decide`]/forward logic as an on-the-wire query, so the result
1361/// (and its anti-leak posture) matches what a tailnet client would observe.
1362pub struct Query {
1363    /// The canonical name to resolve (e.g. `example.com`, no trailing dot).
1364    pub name: String,
1365    /// The DNS query type (`1`=A, `28`=AAAA, `12`=PTR, or any other RFC 1035 TYPE).
1366    pub qtype: u16,
1367}
1368
1369/// The outcome of a `Query`: the raw DNS response bytes, the RCODE, and which upstream resolvers
1370/// (if any) were consulted. The response is returned as raw bytes (matching Go `LocalClient.QueryDNS`)
1371/// rather than parsed records — this fork's wire codec has no answer-record decoder.
1372///
1373/// (`Query` is the crate-internal actor message; not linked here as it is a private item — a
1374/// `pub` doc cannot intra-doc-link to it without erroring under the doc-lint gate.)
1375#[derive(Debug, Clone, kameo::Reply)]
1376pub struct DnsQueryResult {
1377    /// The raw DNS response datagram (header + question + any answer records).
1378    pub response: Vec<u8>,
1379    /// The RCODE from the response header's low 4 bits (`0`=NoError, `2`=SERVFAIL, `3`=NXDOMAIN,
1380    /// `5`=Refused, …).
1381    pub rcode: u8,
1382    /// The upstream resolver(s) the query was forwarded to. For a UDP forward this is the candidate
1383    /// list tried in order (the forwarder returns on the first that answers); for an exit-node DoH
1384    /// forward it is the single DoH endpoint. Empty for a locally-answered query (an authoritative
1385    /// tailnet name, a NODATA, or a fail-closed NXDOMAIN — nothing egressed).
1386    pub resolvers_consulted: Vec<SocketAddr>,
1387}
1388
1389impl kameo::Actor for MagicDnsActor {
1390    type Args = (Env, Channel);
1391    type Error = Error;
1392
1393    async fn on_start(
1394        (env, channel): Self::Args,
1395        slf: ActorRef<Self>,
1396    ) -> Result<Self, Self::Error> {
1397        env.subscribe::<Arc<ts_control::StateUpdate>>(&slf).await?;
1398        env.subscribe::<Arc<PeerState>>(&slf).await?;
1399        env.subscribe::<crate::route_updater::ActiveExitNode>(&slf)
1400            .await?;
1401        // The live packet filter, for the peerAPI DoH source gate (`peerapi_doh::dns_source_allowed`,
1402        // Go `isPeerAPIDNSAllowed`). Subscribed here rather than threaded from `Runtime::new` because
1403        // this actor already owns the peerAPI server task and the view it serves from.
1404        env.subscribe::<crate::packetfilter::PacketFilterState>(&slf)
1405            .await?;
1406
1407        // Seed the view with the runtime's IPv6 gate (default off) and the current accept-dns value.
1408        // Subsequent control/peer updates clone-and-modify this view: `enable_ipv6` (set once here)
1409        // is preserved, while `accept_dns` is re-read live from `Env` on every rebuild (it is
1410        // runtime-settable). The seed value is moot — no query is served before the first
1411        // StateUpdate — but seeding it keeps the pre-update view internally consistent.
1412        let (view_tx, view_rx) = watch::channel(Arc::new(DnsView {
1413            enable_ipv6: env.enable_ipv6,
1414            accept_dns: env.accept_dns(),
1415            ..DnsView::default()
1416        }));
1417
1418        // The peerAPI DoH source gate reads the *current* filter when a query arrives; the bus has
1419        // no replay, so the latest published filter rides a `watch` cell the server task holds a
1420        // receiver on. `None` until control's first compiled filter — a refusal, not an allow.
1421        let (filter_tx, filter_rx) = watch::channel(None);
1422
1423        let mut joinset = JoinSet::new();
1424
1425        // Bind the MagicDNS socket. If the bind fails we still start (fail closed: the actor just
1426        // never answers anything) so a transient bind error doesn't take down the runtime.
1427        let addr = SocketAddr::from((MAGIC_DNS_IP, MAGIC_DNS_PORT));
1428        match channel.udp_bind(addr).await {
1429            Ok(socket) => {
1430                tracing::debug!(%addr, "magic dns responder bound");
1431                joinset.spawn(serve(socket, view_rx.clone(), channel.clone()));
1432            }
1433            Err(e) => {
1434                tracing::error!(error = %e, %addr, "magic dns udp bind failed; responder inert");
1435            }
1436        }
1437
1438        // When this node advertises a peerAPI port, run the single peerAPI server on the same shared
1439        // view. It routes `/dns-query` to the exit-node DoH handler (recursive resolution gated by
1440        // `forward_exit_egress`, see `peerapi_doh`) and `/v0/put/<name>` to the Taildrop receive
1441        // handler when a store is configured (access-gated, fail-closed, see `peerapi`).
1442        if let Some(port) = env.peerapi_port {
1443            let channel = channel.clone();
1444            let view_rx = view_rx.clone();
1445            let filter_rx = filter_rx.clone();
1446            let forward_exit_egress = env.forward_exit_egress;
1447            let taildrop = env.taildrop_store.clone();
1448            let funnel_ingress = env.funnel_ingress.clone();
1449            joinset.spawn(crate::peerapi::serve(
1450                channel,
1451                port,
1452                view_rx,
1453                filter_rx,
1454                forward_exit_egress,
1455                taildrop,
1456                funnel_ingress,
1457            ));
1458        }
1459
1460        Ok(Self {
1461            _joinset: joinset,
1462            view_tx,
1463            filter_tx,
1464            env,
1465            channel,
1466        })
1467    }
1468}
1469
1470/// A bare SERVFAIL response header for a [`Query`] whose name could not be encoded into a
1471/// well-formed query (a non-ASCII label or an over-255-byte name). A 12-byte header with QR=1 (this
1472/// is a response) and RCODE=2 (server failure); no question or answer section (we never produced a
1473/// parseable question). Lets `query_dns` return a definite, honest RCODE instead of an empty buffer
1474/// that would read back as a fabricated NoError.
1475fn servfail_response() -> Vec<u8> {
1476    let mut resp = vec![0u8; 12];
1477    // Flags: QR=1 (byte 2, 0x80) + RCODE=2 (low nibble of byte 3). All other bits clear.
1478    resp[2] = 0x80;
1479    resp[3] = 0x02;
1480    resp
1481}
1482
1483impl Message<Query> for MagicDnsActor {
1484    type Reply = DnsQueryResult;
1485
1486    async fn handle(&mut self, query: Query, _ctx: &mut Context<Self, Self::Reply>) -> Self::Reply {
1487        // Synthesize a query packet and drive it through the SAME decide/forward path the serve loop
1488        // uses, against the freshest view — so the result and its anti-leak posture exactly match an
1489        // on-the-wire query. The id is fixed (0): a programmatic query has no concurrent-demux need,
1490        // and `response_matches_query` validates the echoed id against this same buffer.
1491        //
1492        // Normalize the name into labels: strip a single trailing dot (an FQDN's root marker — Go's
1493        // `dnsname.ToFQDN` does the same) and drop empty labels. An empty label would otherwise encode
1494        // as a lone `0x00`, identical to the QNAME root terminator, truncating the wire query and
1495        // corrupting the QTYPE/QCLASS that follow.
1496        let trimmed = query.name.strip_suffix('.').unwrap_or(&query.name);
1497        let labels: Vec<String> = trimmed
1498            .split('.')
1499            .filter(|label| !label.is_empty())
1500            .map(str::to_owned)
1501            .collect();
1502        let qtype = match query.qtype {
1503            1 => ts_dns_wire::QType::A,
1504            28 => ts_dns_wire::QType::Aaaa,
1505            12 => ts_dns_wire::QType::Ptr,
1506            other => ts_dns_wire::QType::Other(other),
1507        };
1508        // Class IN (1) — the only class the responder serves authoritatively (a non-IN class still
1509        // forwards via `forward_or_nodata`, matching the on-the-wire path).
1510        let buf = ts_dns_wire::encode_query(0, &ts_dns_wire::Name(labels), &qtype, 1);
1511
1512        let view = self.view_tx.borrow().clone();
1513
1514        let (response, resolvers_consulted) = match decide(&view, &buf) {
1515            // `decide` returns `None` only when `decode_query` rejects the buffer we just built. With
1516            // the name normalized above that can still happen for a name `encode_query` accepts but
1517            // `decode_query` rejects — a non-ASCII/IDN label (the caller must pass punycode) or a name
1518            // whose wire form exceeds 255 bytes. Surface a SERVFAIL (RCODE 2: "could not process")
1519            // rather than an empty buffer that would read back as a fabricated NoError. The serve loop
1520            // silently drops here (the on-wire client times out); a programmatic caller gets a
1521            // definite, honest error instead.
1522            None => (servfail_response(), Vec::new()),
1523            Some(Decision::Reply(resp)) => (
1524                check_response_size_and_set_tc(&buf, resp, ClientTransport::Udp),
1525                Vec::new(),
1526            ),
1527            Some(Decision::Forward {
1528                upstreams,
1529                query,
1530                servfail,
1531                recursive,
1532            }) => {
1533                let plan = forward_plan(&view, upstreams, recursive);
1534                match plan {
1535                    RecursivePlan::Udp(upstreams) => {
1536                        let resp = forward_query(
1537                            &self.channel,
1538                            &upstreams,
1539                            &query,
1540                            servfail,
1541                            ClientTransport::Udp,
1542                        )
1543                        .await;
1544                        (resp, upstreams)
1545                    }
1546                    RecursivePlan::Doh(doh_addr) => {
1547                        let resp = crate::peerapi_doh::forward_doh(
1548                            &self.channel,
1549                            doh_addr,
1550                            &query,
1551                            servfail,
1552                            ClientTransport::Udp,
1553                        )
1554                        .await;
1555                        // The query egressed via the exit node's DoH endpoint, not a local UDP
1556                        // upstream — report the DoH address as the resolver consulted.
1557                        (resp, vec![doh_addr])
1558                    }
1559                }
1560            }
1561        };
1562
1563        // RCODE is the low 4 bits of the second flags byte (header byte 3).
1564        let rcode = response_rcode(&response).unwrap_or(0);
1565
1566        DnsQueryResult {
1567            response,
1568            rcode,
1569            resolvers_consulted,
1570        }
1571    }
1572}
1573
1574impl Message<Arc<ts_control::StateUpdate>> for MagicDnsActor {
1575    type Reply = ();
1576
1577    async fn handle(
1578        &mut self,
1579        update: Arc<ts_control::StateUpdate>,
1580        _ctx: &mut Context<Self, Self::Reply>,
1581    ) {
1582        // Re-read the live accept-dns cell on every rebuild (it is runtime-settable via
1583        // `Device::set_accept_dns`); `enable_ipv6` is preserved from the seed (set once at spawn).
1584        let accept_dns = self.env.accept_dns();
1585        self.view_tx.send_modify(|view| {
1586            let mut next = (**view).clone();
1587            next.cfg = update.dns_config.clone().unwrap_or_default();
1588            next.self_node = update.node.clone();
1589            next.accept_dns = accept_dns;
1590            *view = Arc::new(next);
1591        });
1592    }
1593}
1594
1595impl Message<Arc<PeerState>> for MagicDnsActor {
1596    type Reply = ();
1597
1598    async fn handle(&mut self, state: Arc<PeerState>, _ctx: &mut Context<Self, Self::Reply>) {
1599        // Re-read the live accept-dns cell on every rebuild: `Device::set_accept_dns` triggers a
1600        // `RepublishState` that lands here, so this is the path that re-applies the gate after a
1601        // runtime toggle (covers the netstack responder AND the peerAPI DoH server sharing the view).
1602        let accept_dns = self.env.accept_dns();
1603        self.view_tx.send_modify(|view| {
1604            let mut next = (**view).clone();
1605            next.peers = Some(state.peers.clone());
1606            next.accept_dns = accept_dns;
1607            *view = Arc::new(next);
1608        });
1609    }
1610}
1611
1612impl Message<crate::route_updater::ActiveExitNode> for MagicDnsActor {
1613    type Reply = ();
1614
1615    async fn handle(
1616        &mut self,
1617        active: crate::route_updater::ActiveExitNode,
1618        _ctx: &mut Context<Self, Self::Reply>,
1619    ) {
1620        // Cache the active exit node's DoH endpoint so the serve loop delegates recursive queries
1621        // to it. `None` (no exit node, or one that can't proxy DNS) keeps recursion local. Resolving
1622        // the address here — once, from the route updater's authoritative selection — means the
1623        // serve loop never re-resolves the selector.
1624        let exit_doh = active.node.as_ref().and_then(|n| n.peerapi_doh_addr());
1625        self.view_tx.send_modify(|view| {
1626            let mut next = (**view).clone();
1627            next.exit_doh = exit_doh;
1628            *view = Arc::new(next);
1629        });
1630    }
1631}
1632
1633impl Message<crate::packetfilter::PacketFilterState> for MagicDnsActor {
1634    type Reply = ();
1635
1636    async fn handle(
1637        &mut self,
1638        filter: crate::packetfilter::PacketFilterState,
1639        _ctx: &mut Context<Self, Self::Reply>,
1640    ) {
1641        // Last write wins; the peerAPI DoH source gate reads whatever is current when a peer's query
1642        // lands, so a policy change reaches it on the next query with no per-connection bookkeeping.
1643        self.filter_tx.send_replace(Some(filter));
1644    }
1645}
1646
1647#[cfg(test)]
1648mod tests {
1649    use ts_control::{StableNodeId, TailnetAddress};
1650
1651    use super::*;
1652
1653    /// Test wrapper: run [`decide`] and extract the reply bytes. These tests configure no
1654    /// upstream resolvers, so an unresolved name fails closed to a `Reply` (NXDOMAIN), never a
1655    /// `Forward`; a `Forward` here is a bug and panics.
1656    fn answer(view: &DnsView, buf: &[u8]) -> Option<Vec<u8>> {
1657        match decide(view, buf)? {
1658            Decision::Reply(resp) => Some(resp),
1659            Decision::Forward { .. } => panic!("unexpected forward in authoritative-only test"),
1660        }
1661    }
1662
1663    /// Build a `Node` named `host.user.ts.net` with a known v4/v6 tailnet address.
1664    fn test_node() -> Node {
1665        Node {
1666            id: 1,
1667            stable_id: StableNodeId("n1".to_string()),
1668            hostname: "host".to_string(),
1669            user_id: 0,
1670            tailnet: Some("user.ts.net".to_string()),
1671            tags: vec![],
1672            addresses: vec![
1673                "100.64.0.1/32".parse().unwrap(),
1674                "fd7a::1/128".parse().unwrap(),
1675            ],
1676            tailnet_address: TailnetAddress {
1677                ipv4: "100.64.0.1/32".parse().unwrap(),
1678                ipv6: "fd7a::1/128".parse().unwrap(),
1679            },
1680            node_key: [0u8; 32].into(),
1681            node_key_expiry: None,
1682            expired: false,
1683            online: None,
1684            last_seen: None,
1685            key_signature: vec![],
1686            machine_key: None,
1687            disco_key: None,
1688            accepted_routes: vec![],
1689            underlay_addresses: vec![],
1690            derp_region: None,
1691            cap: Default::default(),
1692            cap_map: Default::default(),
1693            peerapi_port: None,
1694            peerapi_dns_proxy: false,
1695            is_wireguard_only: false,
1696            exit_node_dns_resolvers: vec![],
1697            peer_relay: false,
1698            ssh_host_keys: vec![],
1699            service_vips: Default::default(),
1700            unsigned_peer_api_only: false,
1701        }
1702    }
1703
1704    /// A view with MagicDNS on and a single peer in the db.
1705    fn view_with_peer() -> DnsView {
1706        let mut db = PeerDb::default();
1707        db.upsert(&test_node());
1708
1709        DnsView {
1710            cfg: DnsConfig {
1711                magic_dns: true,
1712                search_domains: vec!["user.ts.net".to_string()],
1713                ..Default::default()
1714            },
1715            peers: Some(Arc::new(db)),
1716            self_node: None,
1717            exit_doh: None,
1718            enable_ipv6: false,
1719            accept_dns: true,
1720        }
1721    }
1722
1723    /// Build a raw DNS query buffer for `labels` with the given id, qtype, qclass.
1724    fn build_query(id: u16, labels: &[&str], qtype: u16, qclass: u16) -> Vec<u8> {
1725        let mut buf: Vec<u8> = Vec::new();
1726        buf.extend_from_slice(&id.to_be_bytes());
1727        buf.extend_from_slice(&0u16.to_be_bytes()); // flags: QR=0 (query)
1728        buf.extend_from_slice(&1u16.to_be_bytes()); // QDCOUNT
1729        buf.extend_from_slice(&0u16.to_be_bytes()); // ANCOUNT
1730        buf.extend_from_slice(&0u16.to_be_bytes()); // NSCOUNT
1731        buf.extend_from_slice(&0u16.to_be_bytes()); // ARCOUNT
1732        for label in labels {
1733            buf.push(label.len() as u8);
1734            buf.extend_from_slice(label.as_bytes());
1735        }
1736        buf.push(0); // root label
1737        buf.extend_from_slice(&qtype.to_be_bytes());
1738        buf.extend_from_slice(&qclass.to_be_bytes());
1739        buf
1740    }
1741
1742    /// `build_query` plus an EDNS(0) OPT record in the additional section advertising `udp_size` as
1743    /// the requestor's UDP payload size (RFC 6891: root NAME, TYPE 41, CLASS = the size), in the
1744    /// only shape Go's `findOPTRecord` accepts: last record in the message, version 0, `RDLEN` 0.
1745    fn build_edns_query(
1746        id: u16,
1747        labels: &[&str],
1748        qtype: u16,
1749        qclass: u16,
1750        udp_size: u16,
1751    ) -> Vec<u8> {
1752        let mut buf = build_query(id, labels, qtype, qclass);
1753        buf[11] = 1; // ARCOUNT = 1
1754        buf.push(0); // NAME: root
1755        buf.extend_from_slice(&41u16.to_be_bytes()); // TYPE: OPT
1756        buf.extend_from_slice(&udp_size.to_be_bytes()); // CLASS: requestor's UDP payload size
1757        buf.extend_from_slice(&0u32.to_be_bytes()); // TTL: extended rcode + flags
1758        buf.extend_from_slice(&0u16.to_be_bytes()); // RDLENGTH: no options
1759        buf
1760    }
1761
1762    /// Like [`build_edns_query`] but with one EDNS option in the OPT record's RDATA, so `RDLEN` is
1763    /// non-zero — the shape a stub resolver sending a DNS cookie (option code 10) produces.
1764    fn build_edns_query_with_option(
1765        id: u16,
1766        labels: &[&str],
1767        qtype: u16,
1768        qclass: u16,
1769        udp_size: u16,
1770        option_code: u16,
1771        option_data: &[u8],
1772    ) -> Vec<u8> {
1773        let mut buf = build_edns_query(id, labels, qtype, qclass, udp_size);
1774        let rdata_len = 4 + option_data.len();
1775        let rdlength_at = buf.len() - 2;
1776        buf[rdlength_at..].copy_from_slice(&(rdata_len as u16).to_be_bytes());
1777        buf.extend_from_slice(&option_code.to_be_bytes());
1778        buf.extend_from_slice(&(option_data.len() as u16).to_be_bytes());
1779        buf.extend_from_slice(option_data);
1780        buf
1781    }
1782
1783    /// Parse a response header: returns `(id, rcode, ancount)`.
1784    fn parse_header(resp: &[u8]) -> (u16, u8, u16) {
1785        let id = u16::from_be_bytes([resp[0], resp[1]]);
1786        let flags = u16::from_be_bytes([resp[2], resp[3]]);
1787        let ancount = u16::from_be_bytes([resp[6], resp[7]]);
1788        (id, (flags & 0x000F) as u8, ancount)
1789    }
1790
1791    #[test]
1792    fn a_query_for_known_peer_answers_v4() {
1793        let view = view_with_peer();
1794        let buf = build_query(0x1234, &["host", "user", "ts", "net"], 1, 1);
1795
1796        let resp = answer(&view, &buf).expect("answers");
1797        let (id, rcode, ancount) = parse_header(&resp);
1798        assert_eq!(id, 0x1234);
1799        assert_eq!(rcode, 0, "NoError");
1800        assert_eq!(ancount, 1);
1801
1802        // The trailing RDATA of the single A record is the peer's tailnet v4 octets.
1803        let tail = &resp[resp.len() - 4..];
1804        assert_eq!(tail, &[100, 64, 0, 1]);
1805    }
1806
1807    #[test]
1808    fn aaaa_query_for_known_peer_is_nodata_when_ipv6_off() {
1809        // Gate OFF (default): an AAAA query for a known overlay peer must return NoError with an
1810        // empty answer (NODATA) — NOT the overlay v6 address, which the IPv4-only client can't
1811        // route. This is the anti-fingerprint / no-dead-connections posture.
1812        let view = view_with_peer();
1813        assert!(!view.enable_ipv6, "default gate is off");
1814        let buf = build_query(0x5, &["host", "user", "ts", "net"], 28, 1);
1815
1816        let resp = answer(&view, &buf).expect("answers");
1817        let (_, rcode, ancount) = parse_header(&resp);
1818        assert_eq!(rcode, 0, "NoError (NODATA)");
1819        assert_eq!(ancount, 0, "empty answer: no AAAA handed out with IPv6 off");
1820    }
1821
1822    #[test]
1823    fn a_query_still_resolves_when_ipv6_off() {
1824        // Gate OFF must not touch the A (v4) path: the v4 answer is byte-for-byte unchanged.
1825        let view = view_with_peer();
1826        let buf = build_query(0x6, &["host", "user", "ts", "net"], 1, 1);
1827
1828        let resp = answer(&view, &buf).expect("answers");
1829        let (_, rcode, ancount) = parse_header(&resp);
1830        assert_eq!(rcode, 0, "NoError");
1831        assert_eq!(ancount, 1);
1832        let tail = &resp[resp.len() - 4..];
1833        assert_eq!(tail, &[100, 64, 0, 1]);
1834    }
1835
1836    #[test]
1837    fn aaaa_query_for_known_peer_answers_v6_when_ipv6_on() {
1838        // Gate ON: historical behavior — answer AAAA from the overlay v6 address.
1839        let mut view = view_with_peer();
1840        view.enable_ipv6 = true;
1841        let buf = build_query(0x5, &["host", "user", "ts", "net"], 28, 1);
1842
1843        let resp = answer(&view, &buf).expect("answers");
1844        let (_, rcode, ancount) = parse_header(&resp);
1845        assert_eq!(rcode, 0, "NoError");
1846        assert_eq!(ancount, 1);
1847
1848        let expected = "fd7a::1".parse::<std::net::Ipv6Addr>().unwrap().octets();
1849        let tail = &resp[resp.len() - 16..];
1850        assert_eq!(tail, expected);
1851    }
1852
1853    #[test]
1854    fn aaaa_for_unknown_tailnet_name_is_nxdomain_not_forwarded_with_ipv6_off() {
1855        // Anti-leak, unchanged by the gate: an AAAA for a name under the tailnet suffix that has no
1856        // overlay match still fails closed to NXDOMAIN — never forwarded to a recursive upstream,
1857        // even with resolvers configured. (Gate OFF only changes the *positive* overlay match into
1858        // NODATA; a non-match still routes through `forward_or_nxdomain`.)
1859        let mut db = PeerDb::default();
1860        db.upsert(&test_node());
1861        let view = DnsView {
1862            cfg: DnsConfig {
1863                magic_dns: true,
1864                search_domains: vec!["user.ts.net".to_string()],
1865                fallback_resolvers: vec![DnsResolver {
1866                    transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
1867                    use_with_exit_node: false,
1868                }],
1869                ..Default::default()
1870            },
1871            peers: Some(Arc::new(db)),
1872            self_node: None,
1873            exit_doh: None,
1874            enable_ipv6: false,
1875            accept_dns: true,
1876        };
1877        let buf = build_query(0x5A, &["ghost", "user", "ts", "net"], 28, 1);
1878
1879        match decide(&view, &buf).expect("decides") {
1880            Decision::Reply(resp) => {
1881                let (_, rcode, _) = parse_header(&resp);
1882                assert_eq!(rcode, 3, "NxDomain: tailnet AAAA not leaked upstream");
1883            }
1884            Decision::Forward { .. } => panic!("tailnet AAAA must never be forwarded"),
1885        }
1886    }
1887
1888    #[test]
1889    fn bare_hostname_resolves() {
1890        // The name index also stores the bare hostname.
1891        let view = view_with_peer();
1892        let buf = build_query(0x7, &["host"], 1, 1);
1893
1894        let resp = answer(&view, &buf).expect("answers");
1895        let (_, rcode, ancount) = parse_header(&resp);
1896        assert_eq!(rcode, 0);
1897        assert_eq!(ancount, 1);
1898    }
1899
1900    #[test]
1901    fn unknown_off_tailnet_name_with_no_upstream_is_servfail() {
1902        // An off-tailnet name with no resolver configured cannot be forwarded. Go answers SERVFAIL
1903        // (a soft "couldn't resolve"), not NXDOMAIN — asserting non-existence of a real name we
1904        // simply have no upstream for would poison a downstream stub's negative cache. (A *tailnet*
1905        // name with no overlay match stays NXDOMAIN — see `tailnet_name_is_never_forwarded` — and a
1906        // negative split-DNS route stays NXDOMAIN — see `negative_route_is_nxdomain_not_forwarded`.)
1907        let view = view_with_peer();
1908        let buf = build_query(0x9, &["nope", "example", "com"], 1, 1);
1909
1910        let resp = answer(&view, &buf).expect("answers");
1911        let (_, rcode, ancount) = parse_header(&resp);
1912        assert_eq!(
1913            rcode, 2,
1914            "ServFail: off-tailnet name, nothing to forward to"
1915        );
1916        assert_eq!(ancount, 0);
1917    }
1918
1919    #[test]
1920    fn magic_dns_off_is_refused() {
1921        // Fail closed: with MagicDNS disabled, even a known name is refused.
1922        let mut view = view_with_peer();
1923        view.cfg.magic_dns = false;
1924        let buf = build_query(0xAB, &["host", "user", "ts", "net"], 1, 1);
1925
1926        let resp = answer(&view, &buf).expect("answers");
1927        let (_, rcode, ancount) = parse_header(&resp);
1928        assert_eq!(rcode, 5, "Refused");
1929        assert_eq!(ancount, 0);
1930    }
1931
1932    #[test]
1933    fn accept_dns_false_refuses_otherwise_answerable_query() {
1934        // The accept-dns gate (Go `CorpDNS`): with `accept_dns == false` the node ignores the
1935        // tailnet DNS config, so even a known peer name that would normally answer authoritatively is
1936        // REFUSED (the responder serves nothing) — mirroring Go applying an empty `dns.Config`.
1937        let mut view = view_with_peer();
1938        assert!(view.cfg.magic_dns, "MagicDNS itself is on");
1939        view.accept_dns = false;
1940        let buf = build_query(0xDD, &["host", "user", "ts", "net"], 1, 1);
1941
1942        let resp = answer(&view, &buf).expect("answers");
1943        let (_, rcode, ancount) = parse_header(&resp);
1944        assert_eq!(rcode, 5, "Refused: accept_dns off ⇒ serve nothing");
1945        assert_eq!(ancount, 0);
1946
1947        // Flip accept_dns back ON (the config was never destroyed, only gated): the same query now
1948        // answers authoritatively — proving the OFF→ON restore is automatic.
1949        view.accept_dns = true;
1950        let resp = answer(&view, &buf).expect("answers");
1951        let (_, rcode, ancount) = parse_header(&resp);
1952        assert_eq!(rcode, 0, "NoError: accept_dns on ⇒ the known peer answers");
1953        assert_eq!(ancount, 1);
1954        let tail = &resp[resp.len() - 4..];
1955        assert_eq!(tail, &[100, 64, 0, 1], "the peer's tailnet v4 is served");
1956    }
1957
1958    #[test]
1959    fn default_view_serves_nothing() {
1960        // The default (no dns_config seen) has magic_dns == false: fail closed.
1961        let view = DnsView::default();
1962        let buf = build_query(0x1, &["host", "user", "ts", "net"], 1, 1);
1963
1964        let resp = answer(&view, &buf).expect("answers");
1965        let (_, rcode, _) = parse_header(&resp);
1966        assert_eq!(rcode, 5, "Refused");
1967    }
1968
1969    #[test]
1970    fn unsupported_qtype_on_tailnet_name_is_nodata_not_refused() {
1971        // TXT (type 16) for a tailnet-authoritative name: the name exists but we hold no TXT, so —
1972        // like Go — return NODATA (empty NOERROR), NOT REFUSED (which would make a stub abandon the
1973        // resolver) and NOT NXDOMAIN (the name exists). The name is never forwarded (anti-leak).
1974        let view = view_with_peer();
1975        let buf = build_query(0x1, &["host", "user", "ts", "net"], 16, 1);
1976
1977        let resp = answer(&view, &buf).expect("answers");
1978        let (_, rcode, ancount) = parse_header(&resp);
1979        assert_eq!(rcode, 0, "NoError (NODATA), not Refused");
1980        assert_eq!(ancount, 0, "no answer records (NODATA)");
1981    }
1982
1983    #[test]
1984    fn unsupported_qtype_off_tailnet_forwards_or_servfails() {
1985        // A non-A/AAAA/PTR qtype for an OFF-tailnet name must be forwardable like A/AAAA — never
1986        // REFUSED. With no upstream configured in this view it soft-fails to SERVFAIL (the same
1987        // disposition an off-tailnet A query gets here), proving the qtype no longer short-circuits
1988        // to REFUSED. HTTPS/SVCB is type 65 (the browser HTTP/3 + ECH case the old REFUSED broke).
1989        let view = view_with_peer();
1990        let buf = build_query(0x1, &["example", "com"], 65, 1);
1991
1992        let resp = answer(&view, &buf).expect("answers");
1993        let (_, rcode, _) = parse_header(&resp);
1994        assert_eq!(
1995            rcode, 2,
1996            "off-tailnet, no upstream -> SERVFAIL (forwardable, not Refused)"
1997        );
1998    }
1999
2000    #[test]
2001    fn unimplemented_qtype_on_tailnet_name_is_notimp() {
2002        // NS (2), SOA (6), HINFO (13), AXFR (252) for a tailnet-authoritative name must answer NOTIMP
2003        // (rcode 4), matching Go `resolveLocal`'s `case dns.TypeNS, dns.TypeSOA, dns.TypeAXFR,
2004        // dns.TypeHINFO: return RCodeNotImplemented`. Returning NODATA (rcode 0) here was a clean
2005        // fingerprint (a `dig SOA user.ts.net` answer differs from real tailscaled). The name is
2006        // still never forwarded (anti-leak).
2007        let view = view_with_peer();
2008        for qtype in [2u16, 6, 13, 252] {
2009            let buf = build_query(0x1, &["host", "user", "ts", "net"], qtype, 1);
2010            let resp = answer(&view, &buf).expect("answers");
2011            let (_, rcode, ancount) = parse_header(&resp);
2012            assert_eq!(rcode, 4, "qtype {qtype} on a tailnet name must be NOTIMP");
2013            assert_eq!(ancount, 0, "NOTIMP carries no answer records");
2014        }
2015    }
2016
2017    #[test]
2018    fn unimplemented_qtype_off_tailnet_still_forwards_not_notimp() {
2019        // The NOTIMP disposition is ONLY for a name we are authoritative for. An NS query for an
2020        // off-tailnet name must still forward (here: SERVFAIL, no upstream) — NOT NOTIMP — exactly
2021        // like the off-tailnet HTTPS/SVCB case above. Guards the NOTIMP change against over-reach.
2022        let view = view_with_peer();
2023        let buf = build_query(0x1, &["example", "com"], 2, 1); // NS, off-tailnet
2024        let resp = answer(&view, &buf).expect("answers");
2025        let (_, rcode, _) = parse_header(&resp);
2026        assert_eq!(
2027            rcode, 2,
2028            "off-tailnet NS -> SERVFAIL (forwardable), not NOTIMP"
2029        );
2030    }
2031
2032    #[test]
2033    fn malformed_query_is_dropped() {
2034        // A response (QR bit set) is not a query; we drop it (no answer).
2035        let mut buf = build_query(0x1, &["host"], 1, 1);
2036        buf[2] = 0x80; // set QR bit
2037        assert!(answer(&view_with_peer(), &buf).is_none());
2038    }
2039
2040    #[test]
2041    fn ptr_for_known_ip_answers_fqdn() {
2042        let view = view_with_peer();
2043        // Reverse name for 100.64.0.1 => 1.0.64.100.in-addr.arpa
2044        let buf = build_query(0x33, &["1", "0", "64", "100", "in-addr", "arpa"], 12, 1);
2045
2046        let resp = answer(&view, &buf).expect("answers");
2047        let (_, rcode, ancount) = parse_header(&resp);
2048        assert_eq!(rcode, 0, "NoError");
2049        assert_eq!(ancount, 1);
2050
2051        // The PTR rdata encodes the peer's fqdn "host.user.ts.net" as length-prefixed labels.
2052        let expected = {
2053            let mut out = Vec::new();
2054            for label in ["host", "user", "ts", "net"] {
2055                out.push(label.len() as u8);
2056                out.extend_from_slice(label.as_bytes());
2057            }
2058            out.push(0);
2059            out
2060        };
2061        let tail = &resp[resp.len() - expected.len()..];
2062        assert_eq!(tail, expected.as_slice());
2063    }
2064
2065    #[test]
2066    fn ptr_for_unknown_public_ip_off_tailnet_is_servfail() {
2067        let view = view_with_peer();
2068        // 9.9.9.9 is a public IP, not a known tailnet IP and not in the CGNAT reverse zone — so its
2069        // reverse query is an ordinary off-tailnet name. With no upstream to forward it to, that is
2070        // SERVFAIL (soft), not NXDOMAIN. (A CGNAT/ip6.arpa reverse for an unmatched tailnet IP still
2071        // fails closed to NXDOMAIN as an anti-leak guard — see `ptr_for_unknown_tailnet_ip_*`.)
2072        let buf = build_query(0x34, &["9", "9", "9", "9", "in-addr", "arpa"], 12, 1);
2073
2074        let resp = answer(&view, &buf).expect("answers");
2075        let (_, rcode, _) = parse_header(&resp);
2076        assert_eq!(
2077            rcode, 2,
2078            "ServFail: off-tailnet public-IP reverse, no upstream"
2079        );
2080    }
2081
2082    #[test]
2083    fn ptr_for_unknown_tailnet_ip_is_nxdomain_not_forwarded() {
2084        // A view WITH an upstream resolver: an off-tailnet reverse query would forward, but a
2085        // reverse query for an unmatched IP in the CGNAT range (100.64.0.0/10) must fail closed to
2086        // NXDOMAIN — the probed tailnet IP must never leak upstream.
2087        let mut db = PeerDb::default();
2088        db.upsert(&test_node());
2089        let view = DnsView {
2090            cfg: DnsConfig {
2091                magic_dns: true,
2092                search_domains: vec!["user.ts.net".to_string()],
2093                fallback_resolvers: vec![DnsResolver {
2094                    transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
2095                    use_with_exit_node: false,
2096                }],
2097                ..Default::default()
2098            },
2099            peers: Some(Arc::new(db)),
2100            self_node: None,
2101            exit_doh: None,
2102            enable_ipv6: false,
2103            accept_dns: true,
2104        };
2105
2106        // 100.64.0.9 is in CGNAT range but owned by no peer => NXDOMAIN, never a Forward.
2107        let buf = build_query(0x35, &["9", "0", "64", "100", "in-addr", "arpa"], 12, 1);
2108        match decide(&view, &buf).expect("decides") {
2109            Decision::Reply(resp) => {
2110                let (_, rcode, _) = parse_header(&resp);
2111                assert_eq!(rcode, 3, "NxDomain");
2112            }
2113            Decision::Forward { .. } => {
2114                panic!("tailnet CGNAT PTR must never be forwarded upstream")
2115            }
2116        }
2117    }
2118
2119    /// Anti-leak regression for the exotic-qtype forward path: a NON-PTR query (TXT, type 16) for a
2120    /// tailnet CGNAT reverse name, with an upstream configured, must STILL fail closed to NXDOMAIN —
2121    /// never forward. The PTR arm guards this, but the `QType::Other` path routes through
2122    /// `forward_or_nodata`, which must re-apply the reverse-zone guard or the tailnet IP leaks.
2123    #[test]
2124    fn exotic_qtype_for_tailnet_cgnat_reverse_is_nxdomain_not_forwarded() {
2125        let mut db = PeerDb::default();
2126        db.upsert(&test_node());
2127        let view = DnsView {
2128            cfg: DnsConfig {
2129                magic_dns: true,
2130                search_domains: vec!["user.ts.net".to_string()],
2131                fallback_resolvers: vec![DnsResolver {
2132                    transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
2133                    use_with_exit_node: false,
2134                }],
2135                ..Default::default()
2136            },
2137            peers: Some(Arc::new(db)),
2138            self_node: None,
2139            exit_doh: None,
2140            enable_ipv6: false,
2141            accept_dns: true,
2142        };
2143
2144        // TXT (16) for a CGNAT reverse name => NXDOMAIN, never a Forward (no tailnet-IP leak).
2145        let buf = build_query(0x36, &["9", "0", "64", "100", "in-addr", "arpa"], 16, 1);
2146        match decide(&view, &buf).expect("decides") {
2147            Decision::Reply(resp) => {
2148                let (_, rcode, _) = parse_header(&resp);
2149                assert_eq!(rcode, 3, "NxDomain");
2150            }
2151            Decision::Forward { .. } => {
2152                panic!("a non-PTR query for a tailnet CGNAT reverse name must never forward")
2153            }
2154        }
2155    }
2156
2157    /// Same anti-leak guard for an `ip6.arpa` reverse name under an exotic qtype: must NXDOMAIN, not
2158    /// forward (revealing a tailnet ULA was probed).
2159    #[test]
2160    fn exotic_qtype_for_ip6_arpa_is_nxdomain_not_forwarded() {
2161        let view = view_with_routes(
2162            std::collections::BTreeMap::new(),
2163            vec![udp("9.9.9.9:53")],
2164            vec![],
2165        );
2166        // An ip6.arpa reverse name with a TXT (16) qtype must fail closed.
2167        let buf = build_query(
2168            0x37,
2169            &[
2170                "1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0",
2171                "a", "7", "d", "f", "ip6", "arpa",
2172            ],
2173            16,
2174            1,
2175        );
2176        match decide(&view, &buf).expect("decides") {
2177            Decision::Reply(resp) => {
2178                let (_, rcode, _) = parse_header(&resp);
2179                assert_eq!(rcode, 3, "NxDomain");
2180            }
2181            Decision::Forward { .. } => panic!("an ip6.arpa exotic-qtype query must never forward"),
2182        }
2183    }
2184
2185    #[test]
2186    fn is_tailnet_cgnat_classifies_range() {
2187        assert!(is_tailnet_cgnat("100.64.0.0".parse().unwrap()));
2188        assert!(is_tailnet_cgnat("100.64.0.1".parse().unwrap()));
2189        assert!(is_tailnet_cgnat("100.127.255.255".parse().unwrap()));
2190        // Outside the /10:
2191        assert!(!is_tailnet_cgnat("100.63.255.255".parse().unwrap()));
2192        assert!(!is_tailnet_cgnat("100.128.0.0".parse().unwrap()));
2193        assert!(!is_tailnet_cgnat("9.9.9.9".parse().unwrap()));
2194        // The MagicDNS resolver IP 100.100.100.100 is itself inside the /10.
2195        assert!(is_tailnet_cgnat("100.100.100.100".parse().unwrap()));
2196    }
2197
2198    #[test]
2199    fn response_matches_query_validates_id_and_qr() {
2200        // query id 0x1234, QR=0
2201        let query = build_query(0x1234, &["a", "com"], 1, 1);
2202
2203        // A well-formed response: same id, QR=1.
2204        let mut good = query.clone();
2205        good[2] |= 0x80;
2206        assert!(response_matches_query(&query, &good));
2207
2208        // Same id but QR still 0 (not a response): rejected.
2209        assert!(!response_matches_query(&query, &query));
2210
2211        // QR=1 but a different transaction id: rejected (off-path forgery).
2212        let mut wrong_id = good.clone();
2213        wrong_id[0] ^= 0xFF;
2214        assert!(!response_matches_query(&query, &wrong_id));
2215
2216        // Too-short buffers: rejected.
2217        assert!(!response_matches_query(&query, &[0u8; 2]));
2218        assert!(!response_matches_query(&[0u8; 3], &good));
2219    }
2220
2221    #[test]
2222    fn self_node_resolves_when_no_peer_match() {
2223        // With the peer db empty but a self node set, the self node answers for its own name.
2224        let view = DnsView {
2225            cfg: DnsConfig {
2226                magic_dns: true,
2227                search_domains: vec![],
2228                ..Default::default()
2229            },
2230            peers: None,
2231            self_node: Some(test_node()),
2232            exit_doh: None,
2233            enable_ipv6: false,
2234            accept_dns: true,
2235        };
2236        let buf = build_query(0x44, &["host", "user", "ts", "net"], 1, 1);
2237
2238        let resp = answer(&view, &buf).expect("answers");
2239        let (_, rcode, ancount) = parse_header(&resp);
2240        assert_eq!(rcode, 0);
2241        assert_eq!(ancount, 1);
2242        let tail = &resp[resp.len() - 4..];
2243        assert_eq!(tail, &[100, 64, 0, 1]);
2244    }
2245
2246    #[test]
2247    fn partially_qualified_name_resolves_via_search_domain() {
2248        // "host.user" is not indexed directly, but the "user.ts.net" search domain qualifies it
2249        // to "host.user.user.ts.net"... which does NOT match. The realistic case is "host" (bare,
2250        // already indexed) and "host.user.ts.net" (fqdn). Verify a name needing suffix expansion:
2251        // with search domain "ts.net" the partially-qualified "host.user" => "host.user.ts.net".
2252        let mut view = view_with_peer();
2253        view.cfg.search_domains = vec!["ts.net".to_string()];
2254        let buf = build_query(0x55, &["host", "user"], 1, 1);
2255
2256        let resp = answer(&view, &buf).expect("answers");
2257        let (_, rcode, ancount) = parse_header(&resp);
2258        assert_eq!(rcode, 0, "NoError via search-domain expansion");
2259        assert_eq!(ancount, 1);
2260        let tail = &resp[resp.len() - 4..];
2261        assert_eq!(tail, &[100, 64, 0, 1]);
2262    }
2263
2264    #[test]
2265    fn extra_record_a_answers_when_no_peer_match() {
2266        // A control-pushed static A record answers for a non-peer name, fail-closed otherwise.
2267        let mut view = view_with_peer();
2268        view.cfg.extra_records = vec![ts_control::ExtraRecord {
2269            name: "static.user.ts.net".to_string(),
2270            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2271        }];
2272        let buf = build_query(0x77, &["static", "user", "ts", "net"], 1, 1);
2273
2274        let resp = answer(&view, &buf).expect("answers");
2275        let (_, rcode, ancount) = parse_header(&resp);
2276        assert_eq!(rcode, 0, "NoError from extra record");
2277        assert_eq!(ancount, 1);
2278        let tail = &resp[resp.len() - 4..];
2279        assert_eq!(tail, &[100, 64, 0, 9]);
2280    }
2281
2282    #[test]
2283    fn extra_record_matches_query_case_insensitively() {
2284        // The query name is canonicalized (lowercased) at decode time, so a mixed-case query
2285        // matches a lowercase extra record.
2286        let mut view = view_with_peer();
2287        view.cfg.extra_records = vec![ts_control::ExtraRecord {
2288            name: "static.user.ts.net".to_string(),
2289            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2290        }];
2291        let buf = build_query(0x7A, &["Static", "User", "TS", "net"], 1, 1);
2292
2293        let resp = answer(&view, &buf).expect("answers");
2294        let (_, rcode, ancount) = parse_header(&resp);
2295        assert_eq!(rcode, 0, "NoError: case-insensitive match");
2296        assert_eq!(ancount, 1);
2297        let tail = &resp[resp.len() - 4..];
2298        assert_eq!(tail, &[100, 64, 0, 9]);
2299    }
2300
2301    #[test]
2302    fn extra_record_not_expanded_by_search_domain() {
2303        // Unlike peer names, an extra record is matched as an FQDN only: a bare query that would
2304        // need search-domain expansion to reach the record name must NOT resolve.
2305        let mut view = view_with_peer();
2306        view.cfg.extra_records = vec![ts_control::ExtraRecord {
2307            name: "static.user.ts.net".to_string(),
2308            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2309        }];
2310        // "static" would only reach "static.user.ts.net" via the "user.ts.net" search domain.
2311        let buf = build_query(0x7B, &["static"], 1, 1);
2312
2313        let resp = answer(&view, &buf).expect("answers");
2314        let (_, rcode, _) = parse_header(&resp);
2315        // Not search-expanded → treated as the bare off-tailnet name "static", which has no upstream
2316        // here, so SERVFAIL (soft). The point of the test — that the extra record is NOT reachable
2317        // via search expansion — holds regardless of the failure rcode.
2318        assert_eq!(
2319            rcode, 2,
2320            "ServFail: bare 'static' is not search-expanded to the extra record"
2321        );
2322    }
2323
2324    #[test]
2325    fn extra_record_aaaa_family_is_isolated() {
2326        // An A-only extra record must NOT answer an AAAA query for the same name (NxDomain).
2327        let mut view = view_with_peer();
2328        view.cfg.extra_records = vec![ts_control::ExtraRecord {
2329            name: "v4only.user.ts.net".to_string(),
2330            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2331        }];
2332        let buf = build_query(0x78, &["v4only", "user", "ts", "net"], 28, 1);
2333
2334        let resp = answer(&view, &buf).expect("answers");
2335        let (_, rcode, _) = parse_header(&resp);
2336        assert_eq!(rcode, 3, "NxDomain: A record does not satisfy AAAA");
2337    }
2338
2339    #[test]
2340    fn extra_record_ignored_when_magic_dns_off() {
2341        // Fail closed: extra records are never served while MagicDNS is disabled.
2342        let mut view = view_with_peer();
2343        view.cfg.magic_dns = false;
2344        view.cfg.extra_records = vec![ts_control::ExtraRecord {
2345            name: "static.user.ts.net".to_string(),
2346            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2347        }];
2348        let buf = build_query(0x79, &["static", "user", "ts", "net"], 1, 1);
2349
2350        let resp = answer(&view, &buf).expect("answers");
2351        let (_, rcode, _) = parse_header(&resp);
2352        assert_eq!(rcode, 5, "Refused");
2353    }
2354
2355    /// The node attribute control sets to make every subdomain of a node resolve to it (Go
2356    /// `tailcfg/nodecap`'s `NodeAttrDNSSubdomainResolve`).
2357    const DNS_SUBDOMAIN_RESOLVE: &str = "dns-subdomain-resolve";
2358
2359    /// A view holding a single peer `host.user.ts.net` that carries the `dns-subdomain-resolve`
2360    /// node attribute, so control has declared every name under it to resolve to its addresses.
2361    fn view_with_subdomain_host() -> DnsView {
2362        let mut node = test_node();
2363        node.cap_map
2364            .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
2365
2366        let mut db = PeerDb::default();
2367        db.upsert(&node);
2368
2369        let mut view = view_with_peer();
2370        view.peers = Some(Arc::new(db));
2371        view
2372    }
2373
2374    #[test]
2375    fn subdomain_of_a_subdomain_host_resolves_to_it() {
2376        // `my.host.user.ts.net` has no record of its own; its parent `host.user.ts.net` carries the
2377        // attribute, so it answers with the parent's address.
2378        let view = view_with_subdomain_host();
2379        let buf = build_query(0x90, &["my", "host", "user", "ts", "net"], 1, 1);
2380
2381        let resp = answer(&view, &buf).expect("answers");
2382        let (_, rcode, ancount) = parse_header(&resp);
2383        assert_eq!(rcode, 0, "NoError from the subdomain host");
2384        assert_eq!(ancount, 1);
2385        assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
2386    }
2387
2388    #[test]
2389    fn a_multi_label_subdomain_of_a_subdomain_host_resolves() {
2390        // The walk climbs every parent, not one level: `be.my.host` reaches `host` just as
2391        // `my.host` does. One level of parent is not what upstream implements.
2392        let view = view_with_subdomain_host();
2393        let buf = build_query(0x91, &["be", "my", "host", "user", "ts", "net"], 1, 1);
2394
2395        let resp = answer(&view, &buf).expect("answers");
2396        let (_, rcode, ancount) = parse_header(&resp);
2397        assert_eq!(rcode, 0, "NoError: the walk is not depth-limited");
2398        assert_eq!(ancount, 1);
2399        assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
2400    }
2401
2402    #[test]
2403    fn subdomain_of_a_peer_without_the_attribute_is_nxdomain() {
2404        // The attribute is what turns the walk on. Without it — the default for every node — a
2405        // subdomain of a peer name is still authoritatively absent.
2406        let view = view_with_peer();
2407        assert!(
2408            !view
2409                .node_by_name("host.user.ts.net")
2410                .expect("peer is present")
2411                .resolves_subdomains(),
2412            "the plain test peer carries no node attribute"
2413        );
2414        let buf = build_query(0x92, &["my", "host", "user", "ts", "net"], 1, 1);
2415
2416        let resp = answer(&view, &buf).expect("answers");
2417        let (_, rcode, ancount) = parse_header(&resp);
2418        assert_eq!(rcode, 3, "NxDomain: no attribute, no subdomain resolution");
2419        assert_eq!(ancount, 0);
2420    }
2421
2422    #[test]
2423    fn an_exact_match_beats_the_subdomain_host() {
2424        // The walk is the *miss* path: a name that resolves exactly — here a control-pushed extra
2425        // record — keeps its own answer, and never takes the parent's.
2426        let mut view = view_with_subdomain_host();
2427        view.cfg.extra_records = vec![ts_control::ExtraRecord {
2428            name: "my.host.user.ts.net".to_string(),
2429            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2430        }];
2431        let buf = build_query(0x93, &["my", "host", "user", "ts", "net"], 1, 1);
2432
2433        let resp = answer(&view, &buf).expect("answers");
2434        let (_, rcode, ancount) = parse_header(&resp);
2435        assert_eq!(rcode, 0, "NoError");
2436        assert_eq!(ancount, 1);
2437        assert_eq!(
2438            &resp[resp.len() - 4..],
2439            &[100, 64, 0, 9],
2440            "the exact record answers, not the subdomain host's address"
2441        );
2442    }
2443
2444    #[test]
2445    fn the_subdomain_walk_stops_at_the_tailnet_zone() {
2446        // A node whose own FQDN *is* the search domain must not make the whole zone a wildcard:
2447        // the walk stops at the zone apex rather than climbing into names we do not serve.
2448        let mut zone_node = test_node();
2449        zone_node.hostname = "user".to_string();
2450        zone_node.tailnet = Some("ts.net".to_string());
2451        zone_node
2452            .cap_map
2453            .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
2454        assert_eq!(zone_node.fqdn(false), "user.ts.net", "the zone apex itself");
2455
2456        let mut db = PeerDb::default();
2457        db.upsert(&zone_node);
2458        let mut view = view_with_peer();
2459        view.peers = Some(Arc::new(db));
2460
2461        for labels in [
2462            ["nothing", "user", "ts", "net"].as_slice(),
2463            ["deeper", "nothing", "user", "ts", "net"].as_slice(),
2464        ] {
2465            let buf = build_query(0x94, labels, 1, 1);
2466            let resp = answer(&view, &buf).expect("answers");
2467            let (_, rcode, ancount) = parse_header(&resp);
2468            assert_eq!(rcode, 3, "NxDomain: the walk stopped at {:?}", labels);
2469            assert_eq!(ancount, 0);
2470        }
2471    }
2472
2473    #[test]
2474    fn the_subdomain_walk_does_not_search_expand_a_bare_label() {
2475        // The peer-name index also holds bare hostnames, so a peer named after a public suffix must
2476        // not swallow every name under it: only a fully-qualified parent is a walk candidate. Go
2477        // cannot do this at all — its resolver does no search-list expansion.
2478        let mut suffix_node = test_node();
2479        suffix_node.hostname = "com".to_string();
2480        suffix_node
2481            .cap_map
2482            .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
2483
2484        let mut db = PeerDb::default();
2485        db.upsert(&suffix_node);
2486        let mut view = view_with_peer();
2487        view.peers = Some(Arc::new(db));
2488
2489        let buf = build_query(0x95, &["www", "example", "com"], 1, 1);
2490        let resp = answer(&view, &buf).expect("answers");
2491        let (_, rcode, ancount) = parse_header(&resp);
2492        assert_eq!(
2493            rcode, 2,
2494            "ServFail: an off-tailnet name with no upstream, NOT the peer named 'com'"
2495        );
2496        assert_eq!(ancount, 0, "no answer manufactured from a bare hostname");
2497
2498        // The qualified form of the same peer still resolves its subdomains: the bound rejects the
2499        // bare label, not the subdomain host.
2500        let buf = build_query(0x96, &["www", "com", "user", "ts", "net"], 1, 1);
2501        let resp = answer(&view, &buf).expect("answers");
2502        let (_, rcode, ancount) = parse_header(&resp);
2503        assert_eq!(rcode, 0, "NoError from com.user.ts.net");
2504        assert_eq!(ancount, 1);
2505        assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
2506    }
2507
2508    #[test]
2509    fn aaaa_for_a_subdomain_host_follows_the_ipv6_gate() {
2510        // The subdomain answer is the parent node's address, so it takes the same AAAA gate an
2511        // exact peer match does: NODATA with IPv6 off, the overlay v6 with it on.
2512        let mut view = view_with_subdomain_host();
2513        let buf = build_query(0x97, &["my", "host", "user", "ts", "net"], 28, 1);
2514
2515        let resp = answer(&view, &buf).expect("answers");
2516        let (_, rcode, ancount) = parse_header(&resp);
2517        assert_eq!(rcode, 0, "NoError (NODATA) with the gate off");
2518        assert_eq!(ancount, 0);
2519
2520        view.enable_ipv6 = true;
2521        let resp = answer(&view, &buf).expect("answers");
2522        let (_, rcode, ancount) = parse_header(&resp);
2523        assert_eq!(rcode, 0, "NoError");
2524        assert_eq!(ancount, 1);
2525        let expected = "fd7a::1".parse::<std::net::Ipv6Addr>().unwrap().octets();
2526        assert_eq!(&resp[resp.len() - 16..], expected);
2527    }
2528
2529    #[test]
2530    fn a_subdomain_of_the_self_node_resolves_when_it_has_the_attribute() {
2531        // The walk runs over the same name lookup the exact match uses, so the self node is a
2532        // subdomain host too when control sets the attribute on it.
2533        let mut self_node = test_node();
2534        self_node.hostname = "me".to_string();
2535        self_node
2536            .cap_map
2537            .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
2538
2539        let mut view = view_with_peer();
2540        view.peers = None;
2541        view.self_node = Some(self_node);
2542
2543        let buf = build_query(0x98, &["a", "b", "me", "user", "ts", "net"], 1, 1);
2544        let resp = answer(&view, &buf).expect("answers");
2545        let (_, rcode, ancount) = parse_header(&resp);
2546        assert_eq!(rcode, 0, "NoError from the self node");
2547        assert_eq!(ancount, 1);
2548        assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
2549    }
2550
2551    #[test]
2552    fn non_in_class_on_tailnet_name_is_nodata_not_answered_as_in() {
2553        // A CHAOS-class (3) query for a tailnet name must NOT be answered as IN (no overlay A), and
2554        // must NOT be REFUSED (Go does no class check on the local path). It's an unsupported
2555        // authoritative class -> NODATA (empty NOERROR), and never forwarded (tailnet name).
2556        let view = view_with_peer();
2557        let buf = build_query(0x66, &["host", "user", "ts", "net"], 1, 3);
2558
2559        let resp = answer(&view, &buf).expect("answers");
2560        let (_, rcode, ancount) = parse_header(&resp);
2561        assert_eq!(
2562            rcode, 0,
2563            "NoError (NODATA), not Refused and not an IN answer"
2564        );
2565        assert_eq!(
2566            ancount, 0,
2567            "must not hand out the overlay A for a non-IN class"
2568        );
2569    }
2570
2571    #[test]
2572    fn non_in_class_off_tailnet_forwards_or_servfails() {
2573        // A non-IN class for an OFF-tailnet name is forwardable (Go forwards it), never REFUSED.
2574        // No upstream here -> SERVFAIL, proving the class gate no longer short-circuits to Refused.
2575        let view = view_with_peer();
2576        let buf = build_query(0x66, &["example", "com"], 1, 3);
2577
2578        let resp = answer(&view, &buf).expect("answers");
2579        let (_, rcode, _) = parse_header(&resp);
2580        assert_eq!(
2581            rcode, 2,
2582            "off-tailnet non-IN class, no upstream -> SERVFAIL, not Refused"
2583        );
2584    }
2585
2586    /// A view with MagicDNS on, the `user.ts.net` search domain, and the given split-DNS routes
2587    /// + global resolvers.
2588    fn view_with_routes(
2589        routes: std::collections::BTreeMap<String, Vec<DnsResolver>>,
2590        resolvers: Vec<DnsResolver>,
2591        fallback: Vec<DnsResolver>,
2592    ) -> DnsView {
2593        DnsView {
2594            cfg: DnsConfig {
2595                magic_dns: true,
2596                search_domains: vec!["user.ts.net".to_string()],
2597                routes,
2598                resolvers,
2599                fallback_resolvers: fallback,
2600                ..Default::default()
2601            },
2602            peers: None,
2603            self_node: None,
2604            exit_doh: None,
2605            enable_ipv6: false,
2606            accept_dns: true,
2607        }
2608    }
2609
2610    fn udp(addr: &str) -> DnsResolver {
2611        DnsResolver {
2612            transport: ts_control::ResolverTransport::Udp(addr.parse().unwrap()),
2613            use_with_exit_node: false,
2614        }
2615    }
2616
2617    #[test]
2618    fn split_dns_route_forwards_to_matching_upstream() {
2619        let mut routes = std::collections::BTreeMap::new();
2620        routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
2621        let view = view_with_routes(routes, vec![], vec![]);
2622        let buf = build_query(0x100, &["api", "corp", "example"], 1, 1);
2623
2624        match decide(&view, &buf).expect("decides") {
2625            Decision::Forward { upstreams, .. } => {
2626                assert_eq!(upstreams, vec!["10.0.0.53:53".parse().unwrap()]);
2627            }
2628            Decision::Reply(_) => panic!("expected forward to the split-DNS upstream"),
2629        }
2630    }
2631
2632    #[test]
2633    fn exotic_qtype_off_tailnet_forwards_to_upstream() {
2634        // The core of the fix: an HTTPS/SVCB (type 65) query for an off-tailnet name with a matching
2635        // route must FORWARD to the upstream (verbatim), exactly like an A query would — not REFUSE
2636        // and not NXDOMAIN. This is the browser HTTP/3 + ECH case the old blanket-REFUSE broke.
2637        let mut routes = std::collections::BTreeMap::new();
2638        routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
2639        let view = view_with_routes(routes, vec![], vec![]);
2640        let buf = build_query(0x102, &["api", "corp", "example"], 65, 1);
2641
2642        match decide(&view, &buf).expect("decides") {
2643            Decision::Forward {
2644                upstreams, query, ..
2645            } => {
2646                assert_eq!(upstreams, vec!["10.0.0.53:53".parse().unwrap()]);
2647                assert_eq!(query, buf, "the exotic-qtype query is forwarded verbatim");
2648            }
2649            Decision::Reply(_) => {
2650                panic!("an off-tailnet HTTPS-record query must forward, not reply")
2651            }
2652        }
2653    }
2654
2655    #[test]
2656    fn non_in_class_off_tailnet_forwards_to_upstream() {
2657        // A non-IN class for an off-tailnet routed name forwards too (Go does no class check on the
2658        // local path). Proves the class gate no longer short-circuits to REFUSED before routing.
2659        let mut routes = std::collections::BTreeMap::new();
2660        routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
2661        let view = view_with_routes(routes, vec![], vec![]);
2662        let buf = build_query(0x103, &["api", "corp", "example"], 1, 3);
2663
2664        match decide(&view, &buf).expect("decides") {
2665            Decision::Forward { upstreams, .. } => {
2666                assert_eq!(upstreams, vec!["10.0.0.53:53".parse().unwrap()]);
2667            }
2668            Decision::Reply(_) => {
2669                panic!("an off-tailnet non-IN-class query must forward, not reply")
2670            }
2671        }
2672    }
2673
2674    /// The local responder bounds concurrent in-flight forwards: `serve` acquires one
2675    /// `MAX_INFLIGHT_FORWARDS` permit per spawned forward task and drops the query fail-closed when
2676    /// the pool is exhausted (a client spraying forwardable names can't open unbounded overlay
2677    /// sockets). This pins the gating semantics `serve` relies on — drained pool refuses a new
2678    /// permit; releasing one restores capacity — and the cap constant itself. (The async `serve`
2679    /// loop has no netstack-free test seam, so the semaphore behavior is exercised directly here, the
2680    /// same `Arc<Semaphore>::try_acquire_owned` the loop uses.)
2681    #[test]
2682    fn forward_inflight_cap_fails_closed_when_saturated() {
2683        use std::sync::Arc;
2684
2685        use tokio::sync::Semaphore;
2686
2687        let inflight = Arc::new(Semaphore::new(MAX_INFLIGHT_FORWARDS));
2688
2689        // Drain every permit (one per concurrently in-flight forward).
2690        let mut held = Vec::with_capacity(MAX_INFLIGHT_FORWARDS);
2691        for _ in 0..MAX_INFLIGHT_FORWARDS {
2692            held.push(
2693                inflight
2694                    .clone()
2695                    .try_acquire_owned()
2696                    .expect("permits available below the cap"),
2697            );
2698        }
2699
2700        // At the cap, the next forward is refused — `serve` would drop the query, not spawn.
2701        assert!(
2702            inflight.clone().try_acquire_owned().is_err(),
2703            "a saturated forward pool must refuse a new permit (fail closed)"
2704        );
2705
2706        // Completing an in-flight forward releases its permit and restores capacity.
2707        drop(held.pop());
2708        assert!(
2709            inflight.clone().try_acquire_owned().is_ok(),
2710            "releasing a permit must let the next forward proceed"
2711        );
2712    }
2713
2714    /// A permit moved into a spawned forward task (the `let _permit = permit;` shape `serve` uses)
2715    /// must stay held for the *whole* task body — across the `.await` on the upstream — and release
2716    /// only when the task completes. This guards the regression the saturation test above can't see:
2717    /// "tidying" `let _permit = permit;` to `let _ = permit;` would drop the permit immediately,
2718    /// re-opening unbounded concurrency while leaving the synchronous drain/restore test green. Here a
2719    /// 1-permit pool is consumed by a task that holds it across a yield; the pool must read empty
2720    /// while the task runs and refill once it finishes.
2721    #[tokio::test]
2722    async fn forward_permit_is_held_for_the_task_lifetime_not_dropped_early() {
2723        use std::sync::Arc;
2724
2725        use tokio::sync::Semaphore;
2726
2727        let inflight = Arc::new(Semaphore::new(1));
2728        let permit = inflight
2729            .clone()
2730            .try_acquire_owned()
2731            .expect("the sole permit is available");
2732
2733        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
2734        let (release_tx, release_rx) = tokio::sync::oneshot::channel();
2735        let task = tokio::spawn(async move {
2736            // Same shape as `serve`'s spawned forward: the permit is a named binding moved into the
2737            // task, so it lives until the body ends — not dropped at the `let`.
2738            let _permit = permit;
2739            started_tx.send(()).unwrap();
2740            // Stand in for the `.await` on the upstream forward.
2741            release_rx.await.unwrap();
2742        });
2743
2744        started_rx.await.unwrap();
2745        // While the task runs, the permit it moved in is still held — the pool is empty.
2746        assert!(
2747            inflight.clone().try_acquire_owned().is_err(),
2748            "a permit moved into a running task must stay held across its await"
2749        );
2750
2751        // Let the task finish; its permit drops with the body and capacity returns.
2752        release_tx.send(()).unwrap();
2753        task.await.unwrap();
2754        assert!(
2755            inflight.clone().try_acquire_owned().is_ok(),
2756            "the permit must be released once the task body completes"
2757        );
2758    }
2759
2760    /// The address of the `n`th fake upstream resolver (RFC 5737 documentation range).
2761    fn upstream_addr(n: u8) -> SocketAddr {
2762        SocketAddr::from((Ipv4Addr::new(198, 51, 100, n), 53))
2763    }
2764
2765    /// Turn `query` into an upstream response: echo the header and question back with `QR` set and
2766    /// `rcode` in the header's low nibble, then append `tail` verbatim, counted as `ancount` answer
2767    /// records. The forwarder relays bytes and never parses past the question, so an opaque tail is
2768    /// what tells two upstreams' responses apart — and stands in for the RFC 8914 extended DNS error
2769    /// a real resolver puts in its own SERVFAIL/REFUSED.
2770    fn upstream_response(query: &[u8], rcode: u8, ancount: u16, tail: &[u8]) -> Vec<u8> {
2771        let mut resp = query.to_vec();
2772        resp[2] |= 0x80; // QR = 1 (this is a response)
2773        resp[3] = (resp[3] & 0xF0) | rcode;
2774        resp[6..8].copy_from_slice(&ancount.to_be_bytes());
2775        resp.extend_from_slice(tail);
2776        resp
2777    }
2778
2779    /// One scripted upstream for [`run_forward_walk`]: the upstream's address, and the
2780    /// `(source address, datagram)` it hands back — `None` when nothing came back at all.
2781    type ScriptedUpstream = (SocketAddr, Option<(SocketAddr, Vec<u8>)>);
2782
2783    /// Run the real [`forward_walk`] over a scripted set of upstreams: each entry is
2784    /// `(upstream, answer)`, where `answer` is the `(source address, datagram)` that upstream hands
2785    /// back (`None` = nothing came back — a timeout, a bind/send/recv failure). Returns the bytes
2786    /// the client would get **and** the upstreams the walk actually asked, so a test can tell "the
2787    /// second upstream answered" apart from "the walk stopped at the first".
2788    ///
2789    /// The script stands in for [`ask_upstream`]'s overlay socket exchange only; every decision
2790    /// under test — the source/transaction-id check, the REFUSED/SERVFAIL soft-error rules, which
2791    /// response is relayed — is made by the production code being called.
2792    async fn run_forward_walk(
2793        script: &[ScriptedUpstream],
2794        query: &[u8],
2795        fallback: Vec<u8>,
2796    ) -> (Vec<u8>, Vec<SocketAddr>) {
2797        let upstreams: Vec<SocketAddr> = script.iter().map(|(upstream, _)| *upstream).collect();
2798        let asked = std::cell::RefCell::new(Vec::new());
2799
2800        let response = forward_walk(
2801            &upstreams,
2802            query,
2803            fallback,
2804            ClientTransport::Udp,
2805            |upstream| {
2806                asked.borrow_mut().push(upstream);
2807                let answer = script
2808                    .iter()
2809                    .find(|(scripted, _)| *scripted == upstream)
2810                    .and_then(|(_, answer)| answer.clone());
2811                std::future::ready(answer)
2812            },
2813        )
2814        .await;
2815
2816        (response, asked.into_inner())
2817    }
2818
2819    /// A first upstream answering REFUSED must NOT end the forward. A broken or misconfigured
2820    /// resolver refuses instantly and would otherwise beat a healthy one that is still working,
2821    /// handing the stub resolver a refusal as though it were the answer — complete DNS failure
2822    /// wherever a split-DNS route or a fallback list names more than one resolver.
2823    #[tokio::test]
2824    async fn refused_first_upstream_does_not_end_the_walk() {
2825        let query = build_query(0x201, &["api", "example", "com"], 1, 1);
2826        let (first, second) = (upstream_addr(1), upstream_addr(2));
2827        let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"refused");
2828        let answer = upstream_response(&query, 0, 1, b"the real answer");
2829        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
2830
2831        let (got, asked) = run_forward_walk(
2832            &[
2833                (first, Some((first, refusal))),
2834                (second, Some((second, answer.clone()))),
2835            ],
2836            &query,
2837            fallback,
2838        )
2839        .await;
2840
2841        assert_eq!(
2842            asked,
2843            vec![first, second],
2844            "a REFUSED from the first upstream must not stop the walk"
2845        );
2846        assert_eq!(
2847            got, answer,
2848            "the healthy second upstream's answer is what reaches the client"
2849        );
2850    }
2851
2852    /// SERVFAIL is soft in the same way: the walk goes on and the healthy upstream's answer wins.
2853    #[tokio::test]
2854    async fn servfail_first_upstream_does_not_end_the_walk() {
2855        let query = build_query(0x202, &["api", "example", "com"], 1, 1);
2856        let (first, second) = (upstream_addr(1), upstream_addr(2));
2857        let soft_fail = upstream_response(&query, RCODE_SERVFAIL, 0, b"servfail");
2858        let answer = upstream_response(&query, 0, 1, b"the real answer");
2859        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
2860
2861        let (got, asked) = run_forward_walk(
2862            &[
2863                (first, Some((first, soft_fail))),
2864                (second, Some((second, answer.clone()))),
2865            ],
2866            &query,
2867            fallback,
2868        )
2869        .await;
2870
2871        assert_eq!(asked, vec![first, second], "SERVFAIL is a soft error too");
2872        assert_eq!(
2873            got, answer,
2874            "the second upstream's answer reaches the client"
2875        );
2876    }
2877
2878    /// An RCODE that is *not* soft is an answer: NXDOMAIN ends the walk where it is found, and the
2879    /// upstreams after it are never asked. (Making everything soft would turn a legitimate
2880    /// "no such name" into a needless extra round trip — and, with a second refusing upstream, into
2881    /// a different answer entirely.)
2882    #[tokio::test]
2883    async fn nxdomain_ends_the_walk_at_the_first_upstream() {
2884        let query = build_query(0x203, &["nope", "example", "com"], 1, 1);
2885        let (first, second) = (upstream_addr(1), upstream_addr(2));
2886        let nxdomain = upstream_response(&query, 3, 0, b"no such name");
2887        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
2888
2889        let (got, asked) = run_forward_walk(
2890            &[
2891                (first, Some((first, nxdomain.clone()))),
2892                (
2893                    second,
2894                    Some((second, upstream_response(&query, 0, 1, b"late"))),
2895                ),
2896            ],
2897            &query,
2898            fallback,
2899        )
2900        .await;
2901
2902        assert_eq!(asked, vec![first], "NXDOMAIN is an answer: stop asking");
2903        assert_eq!(got, nxdomain, "and it is what the client gets");
2904    }
2905
2906    /// When every upstream refuses, the client gets the FIRST refusal, byte for byte — not the
2907    /// caller's synthesized SERVFAIL. The upstream's own bytes can carry an RFC 8914 extended DNS
2908    /// error explaining the refusal; a locally built packet throws that away.
2909    #[tokio::test]
2910    async fn every_upstream_refusing_returns_the_first_refusal_verbatim() {
2911        let query = build_query(0x204, &["api", "example", "com"], 1, 1);
2912        let (first, second) = (upstream_addr(1), upstream_addr(2));
2913        let first_refusal =
2914            upstream_response(&query, RCODE_REFUSED, 0, b"first refusal + extended error");
2915        let second_refusal = upstream_response(&query, RCODE_REFUSED, 0, b"second refusal");
2916        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
2917
2918        let (got, asked) = run_forward_walk(
2919            &[
2920                (first, Some((first, first_refusal.clone()))),
2921                (second, Some((second, second_refusal.clone()))),
2922            ],
2923            &query,
2924            fallback.clone(),
2925        )
2926        .await;
2927
2928        assert_eq!(
2929            asked,
2930            vec![first, second],
2931            "every upstream is given its turn"
2932        );
2933        assert_eq!(
2934            got, first_refusal,
2935            "an all-refused forward relays the first upstream's own REFUSED bytes"
2936        );
2937        assert_ne!(
2938            got, fallback,
2939            "the synthesized SERVFAIL must not replace an upstream's own response"
2940        );
2941        assert_ne!(got, second_refusal, "the FIRST refusal is the one kept");
2942    }
2943
2944    /// The first *soft* response is the one kept whichever code it carried: a SERVFAIL followed by a
2945    /// REFUSED relays the upstream's own SERVFAIL, extended error and all, rather than the
2946    /// synthesized one the caller supplied.
2947    #[tokio::test]
2948    async fn every_upstream_soft_failing_returns_the_upstream_servfail_not_the_fallback() {
2949        let query = build_query(0x205, &["api", "example", "com"], 1, 1);
2950        let (first, second) = (upstream_addr(1), upstream_addr(2));
2951        let upstream_servfail = upstream_response(
2952            &query,
2953            RCODE_SERVFAIL,
2954            0,
2955            b"upstream servfail + extended error",
2956        );
2957        let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"second refusal");
2958        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
2959
2960        let (got, _asked) = run_forward_walk(
2961            &[
2962                (first, Some((first, upstream_servfail.clone()))),
2963                (second, Some((second, refusal))),
2964            ],
2965            &query,
2966            fallback.clone(),
2967        )
2968        .await;
2969
2970        assert_eq!(
2971            got, upstream_servfail,
2972            "the upstream's own SERVFAIL is relayed verbatim, keeping any extended DNS error"
2973        );
2974        assert_ne!(got, fallback, "not the locally synthesized SERVFAIL");
2975    }
2976
2977    /// A lone upstream that refuses still has its refusal relayed: with nothing else to wait for,
2978    /// treating REFUSED as soft changes nothing about what the client is told.
2979    #[tokio::test]
2980    async fn lone_refusing_upstream_still_has_its_refusal_relayed() {
2981        let query = build_query(0x206, &["api", "example", "com"], 1, 1);
2982        let only = upstream_addr(1);
2983        let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"refused");
2984        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
2985
2986        let (got, asked) =
2987            run_forward_walk(&[(only, Some((only, refusal.clone())))], &query, fallback).await;
2988
2989        assert_eq!(asked, vec![only]);
2990        assert_eq!(
2991            got, refusal,
2992            "a single upstream's REFUSED is the client's answer"
2993        );
2994    }
2995
2996    /// The anti-poisoning check still runs BEFORE any of the soft-error handling: a datagram whose
2997    /// transaction id is not the one we asked with is discarded outright and never remembered as
2998    /// "the first REFUSED", so an off-path injector cannot plant the response an all-refused forward
2999    /// ends up relaying.
3000    #[tokio::test]
3001    async fn wrong_transaction_id_response_is_discarded_not_remembered_as_a_soft_error() {
3002        let query = build_query(0x207, &["api", "example", "com"], 1, 1);
3003        let only = upstream_addr(1);
3004        let mut poisoned = upstream_response(&query, RCODE_REFUSED, 0, b"injected");
3005        poisoned[0] ^= 0xFF; // a transaction id we never asked with
3006        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3007
3008        let (got, _asked) = run_forward_walk(
3009            &[(only, Some((only, poisoned.clone())))],
3010            &query,
3011            fallback.clone(),
3012        )
3013        .await;
3014
3015        assert_ne!(
3016            got, poisoned,
3017            "a mismatched transaction id must never be relayed"
3018        );
3019        assert_eq!(
3020            got, fallback,
3021            "with the datagram discarded nothing answered, so the synthesized fallback stands"
3022        );
3023    }
3024
3025    /// The same for the source check: a well-formed REFUSED that echoes the question and the
3026    /// transaction id but arrives from an address we did not query is discarded before it can become
3027    /// the forward's remembered soft error.
3028    #[tokio::test]
3029    async fn off_path_source_response_is_discarded_not_remembered_as_a_soft_error() {
3030        let query = build_query(0x208, &["api", "example", "com"], 1, 1);
3031        let (only, off_path) = (upstream_addr(1), upstream_addr(9));
3032        let poisoned = upstream_response(&query, RCODE_REFUSED, 0, b"injected");
3033        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3034
3035        let (got, _asked) = run_forward_walk(
3036            &[(only, Some((off_path, poisoned.clone())))],
3037            &query,
3038            fallback.clone(),
3039        )
3040        .await;
3041
3042        assert_ne!(
3043            got, poisoned,
3044            "a datagram from an unqueried source must never be relayed"
3045        );
3046        assert_eq!(
3047            got, fallback,
3048            "with the datagram discarded nothing answered, so the synthesized fallback stands"
3049        );
3050    }
3051
3052    /// An upstream that says nothing at all (timeout, bind/send/recv failure) is simply skipped, and
3053    /// the next upstream's answer is what the client gets.
3054    #[tokio::test]
3055    async fn silent_upstream_is_skipped_for_the_next_one() {
3056        let query = build_query(0x209, &["api", "example", "com"], 1, 1);
3057        let (first, second) = (upstream_addr(1), upstream_addr(2));
3058        let answer = upstream_response(&query, 0, 1, b"the real answer");
3059        let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3060
3061        let (got, asked) = run_forward_walk(
3062            &[(first, None), (second, Some((second, answer.clone())))],
3063            &query,
3064            fallback,
3065        )
3066        .await;
3067
3068        assert_eq!(asked, vec![first, second]);
3069        assert_eq!(got, answer);
3070    }
3071
3072    #[test]
3073    fn longest_suffix_route_wins() {
3074        let mut routes = std::collections::BTreeMap::new();
3075        routes.insert("example".to_string(), vec![udp("10.0.0.1:53")]);
3076        routes.insert("corp.example".to_string(), vec![udp("10.0.0.2:53")]);
3077        let view = view_with_routes(routes, vec![], vec![]);
3078        let buf = build_query(0x101, &["api", "corp", "example"], 1, 1);
3079
3080        match decide(&view, &buf).expect("decides") {
3081            Decision::Forward { upstreams, .. } => {
3082                assert_eq!(
3083                    upstreams,
3084                    vec!["10.0.0.2:53".parse().unwrap()],
3085                    "longer suffix wins"
3086                );
3087            }
3088            Decision::Reply(_) => panic!("expected forward"),
3089        }
3090    }
3091
3092    #[test]
3093    fn negative_route_is_nxdomain_not_forwarded() {
3094        // An empty upstream list is a negative route: fail closed, never forward.
3095        let mut routes = std::collections::BTreeMap::new();
3096        routes.insert("blocked.example".to_string(), vec![]);
3097        let view = view_with_routes(routes, vec![udp("8.8.8.8:53")], vec![]);
3098        let buf = build_query(0x102, &["x", "blocked", "example"], 1, 1);
3099
3100        match decide(&view, &buf).expect("decides") {
3101            Decision::Reply(resp) => {
3102                let (_, rcode, _) = parse_header(&resp);
3103                assert_eq!(rcode, 3, "NxDomain: negative route is not forwarded");
3104            }
3105            Decision::Forward { .. } => panic!("negative route must not forward"),
3106        }
3107    }
3108
3109    #[test]
3110    fn unrouted_name_forwards_to_fallback_then_global() {
3111        // No route matches: fallback resolvers are preferred over global resolvers.
3112        let view = view_with_routes(
3113            std::collections::BTreeMap::new(),
3114            vec![udp("8.8.8.8:53")],
3115            vec![udp("1.1.1.1:53")],
3116        );
3117        let buf = build_query(0x103, &["example", "com"], 1, 1);
3118
3119        match decide(&view, &buf).expect("decides") {
3120            Decision::Forward { upstreams, .. } => {
3121                assert_eq!(
3122                    upstreams,
3123                    vec!["1.1.1.1:53".parse().unwrap()],
3124                    "fallback preferred"
3125                );
3126            }
3127            Decision::Reply(_) => panic!("expected forward to fallback"),
3128        }
3129    }
3130
3131    #[test]
3132    fn unrouted_name_forwards_to_global_when_no_fallback() {
3133        let view = view_with_routes(
3134            std::collections::BTreeMap::new(),
3135            vec![udp("8.8.8.8:53")],
3136            vec![],
3137        );
3138        let buf = build_query(0x104, &["example", "com"], 1, 1);
3139
3140        match decide(&view, &buf).expect("decides") {
3141            Decision::Forward { upstreams, .. } => {
3142                assert_eq!(upstreams, vec!["8.8.8.8:53".parse().unwrap()]);
3143            }
3144            Decision::Reply(_) => panic!("expected forward to global resolver"),
3145        }
3146    }
3147
3148    #[test]
3149    fn tailnet_name_is_never_forwarded() {
3150        // Anti-leak: a name under a tailnet search domain that has no overlay match must fail
3151        // closed to NXDOMAIN, never leak to an upstream resolver, even with resolvers configured.
3152        let view = view_with_routes(
3153            std::collections::BTreeMap::new(),
3154            vec![udp("8.8.8.8:53")],
3155            vec![udp("1.1.1.1:53")],
3156        );
3157        // "ghost.user.ts.net" is under the tailnet suffix but matches no peer.
3158        let buf = build_query(0x105, &["ghost", "user", "ts", "net"], 1, 1);
3159
3160        match decide(&view, &buf).expect("decides") {
3161            Decision::Reply(resp) => {
3162                let (_, rcode, _) = parse_header(&resp);
3163                assert_eq!(rcode, 3, "NxDomain: tailnet name not leaked upstream");
3164            }
3165            Decision::Forward { .. } => panic!("tailnet name must never be forwarded"),
3166        }
3167    }
3168
3169    #[test]
3170    fn no_resolvers_off_tailnet_is_servfail_not_nxdomain() {
3171        // No route, no resolvers: an OFF-tailnet name cannot be forwarded. Go answers SERVFAIL
3172        // (forwarder.go:1207 "no upstream resolvers set, returning SERVFAIL"), NOT NXDOMAIN — a
3173        // cacheable non-existence for a real name we merely couldn't forward would poison downstream
3174        // stub caches. We still never forward (the name does not leak); we just soft-fail.
3175        let view = view_with_routes(std::collections::BTreeMap::new(), vec![], vec![]);
3176        let buf = build_query(0x106, &["example", "com"], 1, 1);
3177
3178        match decide(&view, &buf).expect("decides") {
3179            Decision::Reply(resp) => {
3180                let (_, rcode, _) = parse_header(&resp);
3181                assert_eq!(
3182                    rcode, 2,
3183                    "ServFail: off-tailnet name with no upstream to forward to"
3184                );
3185            }
3186            Decision::Forward { .. } => panic!("must not forward with no resolvers"),
3187        }
3188    }
3189
3190    #[test]
3191    fn route_with_only_ipv6_upstreams_off_tailnet_is_servfail() {
3192        // A split-DNS route exists but every resolver is IPv6 (filtered out under the IPv4-only
3193        // egress): we have a route yet nowhere to forward. That is an inability to forward an
3194        // off-tailnet name, so SERVFAIL (soft), not a fabricated NXDOMAIN.
3195        let mut routes = std::collections::BTreeMap::new();
3196        routes.insert("corp.example".to_string(), vec![udp("[2001:db8::53]:53")]);
3197        let view = view_with_routes(routes, vec![], vec![]);
3198        let buf = build_query(0x108, &["host", "corp", "example"], 1, 1);
3199
3200        match decide(&view, &buf).expect("decides") {
3201            Decision::Reply(resp) => {
3202                let (_, rcode, _) = parse_header(&resp);
3203                assert_eq!(
3204                    rcode, 2,
3205                    "ServFail: route's resolvers all filtered out (IPv6-only), cannot forward"
3206                );
3207            }
3208            Decision::Forward { .. } => panic!("must not forward when all upstreams are filtered"),
3209        }
3210    }
3211
3212    #[test]
3213    fn overlay_match_wins_over_forwarding() {
3214        // A known peer name resolves authoritatively even when upstream resolvers are configured.
3215        let mut db = PeerDb::default();
3216        db.upsert(&test_node());
3217        let view = DnsView {
3218            cfg: DnsConfig {
3219                magic_dns: true,
3220                search_domains: vec!["user.ts.net".to_string()],
3221                resolvers: vec![udp("8.8.8.8:53")],
3222                ..Default::default()
3223            },
3224            peers: Some(Arc::new(db)),
3225            self_node: None,
3226            exit_doh: None,
3227            enable_ipv6: false,
3228            accept_dns: true,
3229        };
3230        let buf = build_query(0x107, &["host", "user", "ts", "net"], 1, 1);
3231
3232        match decide(&view, &buf).expect("decides") {
3233            Decision::Reply(resp) => {
3234                let (_, rcode, ancount) = parse_header(&resp);
3235                assert_eq!(rcode, 0, "authoritative answer wins");
3236                assert_eq!(ancount, 1);
3237            }
3238            Decision::Forward { .. } => panic!("overlay match must not forward"),
3239        }
3240    }
3241
3242    #[test]
3243    fn ipv6_reverse_ptr_is_nxdomain_not_forwarded() {
3244        // Anti-leak: an `ip6.arpa` reverse PTR for a tailnet ULA (fd7a:…) must fail closed to
3245        // NXDOMAIN, never be forwarded — even with an upstream resolver configured. This fork is
3246        // IPv4-only on the tailnet; forwarding would reveal that a v6 address was probed.
3247        let view = view_with_routes(
3248            std::collections::BTreeMap::new(),
3249            vec![udp("8.8.8.8:53")],
3250            vec![udp("1.1.1.1:53")],
3251        );
3252        // Reverse name for fd7a::1 (nibble-reversed) under ip6.arpa. The exact nibble labels don't
3253        // matter to the guard — any name ending in ip6.arpa must fail closed.
3254        let labels = vec![
3255            "1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0",
3256            "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "a", "7", "d", "f", "ip6",
3257            "arpa",
3258        ];
3259        let buf = build_query(0x200, &labels, 12, 1);
3260
3261        match decide(&view, &buf).expect("decides") {
3262            Decision::Reply(resp) => {
3263                let (_, rcode, _) = parse_header(&resp);
3264                assert_eq!(
3265                    rcode, 3,
3266                    "NxDomain: ip6.arpa reverse must not leak upstream"
3267                );
3268            }
3269            Decision::Forward { .. } => panic!("ip6.arpa PTR must never be forwarded"),
3270        }
3271    }
3272
3273    /// The `TC` bit a truncated UDP answer sets is what sends a stub resolver to TCP (RFC 1035
3274    /// §4.2.1). Setting it *again* on the TCP answer sends that resolver straight back into another
3275    /// retry, so the client's advertised UDP payload size — a property of the datagram it would
3276    /// have been answered in, and one RFC 7766 §8 gives a TCP client no equivalent of — is applied
3277    /// only to a [`ClientTransport::Udp`] client. Same query, same answer, two transports.
3278    #[test]
3279    fn client_udp_limit_is_not_applied_to_a_tcp_client() {
3280        // No EDNS OPT record, so the client's limit is the classic 512 bytes.
3281        let query = build_query(0x310, &["example", "com"], 1, 1);
3282        let mut answer = query.clone();
3283        answer[2] |= 0x80; // make it a response (QR=1)
3284        answer.resize(900, 0xAB); // over 512, under MAX_UPSTREAM_RESPONSE: only the client limit bites
3285
3286        let udp = cap_response(&query, answer.clone(), ClientTransport::Udp);
3287        assert_ne!(
3288            udp[2] & 0x02,
3289            0,
3290            "a UDP client that advertised 512 bytes is told the 900-byte answer is truncated"
3291        );
3292        assert_eq!(udp.len(), 900, "and the body is left intact either way");
3293
3294        let tcp = cap_response(&query, answer, ClientTransport::Tcp);
3295        assert_eq!(
3296            tcp[2] & 0x02,
3297            0,
3298            "the same answer over TCP is NOT marked: the client already did the TCP retry"
3299        );
3300        assert_eq!(tcp.len(), 900, "and is relayed whole");
3301    }
3302
3303    /// The relay cap is a different claim from the client's datagram size, and it holds on both
3304    /// transports: when [`MAX_UPSTREAM_RESPONSE`] really did cut the message, `TC` says so. Handing
3305    /// a TCP client a chopped body with `TC` clear would be a malformed-but-"complete" answer.
3306    #[test]
3307    fn a_chopped_answer_is_marked_truncated_on_both_transports() {
3308        let query = build_edns_query(0x311, &["example", "com"], 1, 1, 4096);
3309        let mut big = query.clone();
3310        big[2] |= 0x80;
3311        big.resize(MAX_UPSTREAM_RESPONSE + 500, 0xAB);
3312
3313        let out = cap_response(&query, big, ClientTransport::Tcp);
3314        assert_eq!(out.len(), MAX_UPSTREAM_RESPONSE, "capped to one datagram");
3315        assert_ne!(
3316            out[2] & 0x02,
3317            0,
3318            "we really did chop the body, so TC is set for a TCP client too"
3319        );
3320    }
3321
3322    #[test]
3323    fn cap_response_sets_tc_when_truncated() {
3324        // An oversize upstream answer is capped to a single datagram AND marked truncated (TC bit)
3325        // so the stub resolver retries over TCP rather than trusting a chopped message. The query
3326        // advertises a big EDNS buffer so only the relay cap can be what fires here.
3327        let query = build_edns_query(0x300, &["example", "com"], 1, 1, 4096);
3328        let mut big = query.clone();
3329        big[2] |= 0x80; // make it a response (QR=1)
3330        big.resize(MAX_UPSTREAM_RESPONSE + 500, 0xAB);
3331
3332        let out = cap_response(&query, big, ClientTransport::Udp);
3333        assert_eq!(out.len(), MAX_UPSTREAM_RESPONSE, "capped to one datagram");
3334        assert_ne!(out[2] & 0x02, 0, "TC bit set on truncation");
3335    }
3336
3337    #[test]
3338    fn cap_response_leaves_small_response_untouched() {
3339        // A response that fits both bounds is returned verbatim with no TC bit forced on.
3340        let query = build_query(0x301, &["example", "com"], 1, 1);
3341        let mut small = query.clone();
3342        small[2] |= 0x80;
3343        let before = small.clone();
3344
3345        let out = cap_response(&query, small, ClientTransport::Udp);
3346        assert_eq!(out, before, "small response unchanged");
3347        assert_eq!(out[2] & 0x02, 0, "TC bit not set when no truncation");
3348    }
3349
3350    #[test]
3351    fn cap_is_a_relay_bound_not_the_read_bound() {
3352        // `forward_query` reads with `recv_from_bytes`, which issues `Recv { max_len: None }`, so
3353        // the netstack has already copied the whole datagram out before `cap_response` runs: the
3354        // cap bounds what we relay, not what we read or allocate. What bounds the read is the
3355        // netstack UDP socket's receive ring (`udp_buffer_size`, which `ts_runtime` leaves at the
3356        // `netcore` default) -- smoltcp drops a datagram larger than that ring at enqueue instead
3357        // of delivering it, and hands us everything up to and including the ring whole. The ring
3358        // being *wider* than the cap is what shows the two are different bounds: the read can put
3359        // more bytes in front of `cap_response` than the cap will relay.
3360        let ring = netstack::netcore::Config::default().udp_buffer_size;
3361        assert!(
3362            ring > MAX_UPSTREAM_RESPONSE,
3363            "the netstack udp receive ring ({ring}) no longer exceeds the relay cap \
3364             ({MAX_UPSTREAM_RESPONSE}): the cap would then be unreachable through this socket, and \
3365             the doc describing it as a relay bound the read can overrun is wrong"
3366        );
3367
3368        // The largest answer the cap passes is relayed byte-for-byte. Ask with an EDNS buffer that
3369        // covers the whole datagram, so the client-limit check (the other half of `cap_response`)
3370        // is not what we are measuring.
3371        let query = build_edns_query(0x302, &["example", "com"], 1, 1, 4096);
3372        let mut largest = query.clone();
3373        largest[2] |= 0x80; // QR=1
3374        largest.resize(MAX_UPSTREAM_RESPONSE, 0xAB);
3375        let before = largest.clone();
3376
3377        let out = cap_response(&query, largest, ClientTransport::Udp);
3378        assert_eq!(out, before, "an answer at the cap must be relayed verbatim");
3379        assert_eq!(
3380            out[2] & 0x02,
3381            0,
3382            "TC must not be set on a datagram that was never chopped"
3383        );
3384    }
3385
3386    #[test]
3387    fn full_ring_datagram_is_chopped_and_marked_truncated() {
3388        // Upstream's bound is `const maxResponseBytes = 4095` (net/dns/resolver/tsdns.go @
3389        // 9ea7cba44591e0cd840c6c94d23274dd222059bf). `sendUDP` reads into `maxResponseBytes+1`
3390        // bytes exactly so a 4096-byte answer is detectable as "did not fit", then cuts it to 4095
3391        // and sets TC. Here the netstack's 4096-byte receive ring plays the part of Go's `+1`: a
3392        // full-ring datagram is the one deliverable size the cap does not pass, and it must come
3393        // back with the same shape a Go forwarder would have produced. With the cap at 4096 this
3394        // datagram was relayed whole with TC clear, while a Go client on the same tailnet answering
3395        // the same query returned 4095 bytes marked truncated.
3396        let ring = netstack::netcore::Config::default().udp_buffer_size;
3397        let query = build_edns_query(0x303, &["example", "com"], 1, 1, 4096);
3398        let mut full_ring = query.clone();
3399        full_ring[2] |= 0x80; // QR=1
3400        full_ring.resize(ring, 0xAB);
3401
3402        let out = cap_response(&query, full_ring, ClientTransport::Udp);
3403        assert_eq!(
3404            out.len(),
3405            4095,
3406            "a full-ring answer must be cut to upstream's maxResponseBytes"
3407        );
3408        assert_ne!(out[2] & 0x02, 0, "TC bit set on the chopped answer");
3409    }
3410
3411    #[test]
3412    fn forwarded_reply_over_512_sets_tc_for_a_plain_query() {
3413        // A query with no EDNS OPT record is limited to 512 bytes (RFC 1035), so a 900-byte
3414        // forwarded reply -- well under the 4095 relay cap, and therefore relayed with TC clear
3415        // before this check existed -- must come back marked truncated, body intact.
3416        let query = build_query(0x400, &["example", "com"], 1, 1);
3417        let mut reply = query.clone();
3418        reply[2] |= 0x80; // QR=1
3419        reply.resize(900, 0xAB);
3420
3421        let out = cap_response(&query, reply.clone(), ClientTransport::Udp);
3422
3423        assert_ne!(
3424            out[2] & 0x02,
3425            0,
3426            "a 900-byte reply to a non-EDNS query must have TC set"
3427        );
3428        assert_eq!(out.len(), 900, "the body is left intact, not chopped");
3429        assert_eq!(
3430            out[3..],
3431            reply[3..],
3432            "only the flags byte carrying TC may differ"
3433        );
3434    }
3435
3436    #[test]
3437    fn forwarded_reply_under_advertised_edns_size_leaves_tc_clear() {
3438        // The same 900-byte reply, but the client advertised a 4096-byte EDNS buffer: it fits, so
3439        // TC must stay clear and the datagram must be relayed byte-for-byte.
3440        let query = build_edns_query(0x401, &["example", "com"], 1, 1, 4096);
3441        let mut reply = query.clone();
3442        reply[2] |= 0x80; // QR=1
3443        reply.resize(900, 0xAB);
3444        let before = reply.clone();
3445
3446        let out = cap_response(&query, reply, ClientTransport::Udp);
3447
3448        assert_eq!(
3449            out, before,
3450            "a reply within the advertised buffer is verbatim"
3451        );
3452        assert_eq!(out[2] & 0x02, 0, "TC must stay clear");
3453    }
3454
3455    /// Go's `findOPTRecord` accepts an OPT record only in the final 11 bytes of the message, with a
3456    /// root NAME, EDNS version 0 and `RDLEN == 0`; anything else is "no EDNS", i.e. the 512-byte
3457    /// RFC 1035 limit. Every rejection below is a case where a laxer reader would honour a large
3458    /// advertised buffer and leave `TC` clear on an answer a Go node marks truncated.
3459    #[test]
3460    fn client_udp_limit_reads_the_opt_record() {
3461        // No OPT record => the RFC 1035 512-byte limit.
3462        let plain = build_query(0x402, &["example", "com"], 1, 1);
3463        assert_eq!(client_udp_limit(&plain), NO_EDNS_UDP_LIMIT);
3464
3465        // An OPT record's CLASS field carries the advertised size.
3466        let edns = build_edns_query(0x403, &["example", "com"], 1, 1, 1232);
3467        assert_eq!(client_udp_limit(&edns), 1232);
3468
3469        // A value below 512 is taken verbatim. RFC 6891 6.2.3 would floor it at 512, but Go does
3470        // not (`maxSize = int(ednsSize)`), so a Rust node that did would leave `TC` clear where a
3471        // Go node on the same tailnet sets it.
3472        let tiny = build_edns_query(0x404, &["example", "com"], 1, 1, 64);
3473        assert_eq!(client_udp_limit(&tiny), 64);
3474
3475        // An OPT record that is not the last record in the message is not read at all: upstream
3476        // only ever looks at the final 11 bytes.
3477        let mut trailing_rr = build_edns_query(0x405, &["example", "com"], 1, 1, 2048);
3478        // A 1-byte-RDATA TXT (type 16) record for the root name, appended after the OPT.
3479        trailing_rr.extend_from_slice(&[0, 0, 16, 0, 1, 0, 0, 0, 0, 0, 1, 0]);
3480        trailing_rr[11] = 2; // ARCOUNT = 2
3481        assert_eq!(client_udp_limit(&trailing_rr), NO_EDNS_UDP_LIMIT);
3482
3483        // An OPT record carrying options — a DNS cookie, EDNS Client Subnet — has RDLEN != 0 and is
3484        // rejected. This is the common case, not a corner: stub resolvers send cookies routinely.
3485        let cookie =
3486            build_edns_query_with_option(0x406, &["example", "com"], 1, 1, 4096, 10, &[0; 8]);
3487        assert_eq!(client_udp_limit(&cookie), NO_EDNS_UDP_LIMIT);
3488
3489        // An unknown EDNS version is left alone rather than guessed at.
3490        let mut future_version = build_edns_query(0x407, &["example", "com"], 1, 1, 4096);
3491        let ttl_at = future_version.len() - 6; // TTL = extended RCODE (1) | VERSION (1) | flags (2)
3492        future_version[ttl_at + 1] = 1; // EDNS version 1
3493        assert_eq!(client_udp_limit(&future_version), NO_EDNS_UDP_LIMIT);
3494
3495        // A non-root OPT NAME is rejected.
3496        let mut named = build_edns_query(0x408, &["example", "com"], 1, 1, 4096);
3497        let name_at = named.len() - 11;
3498        named[name_at] = 0xC0; // a compression pointer where the root label must be
3499        assert_eq!(client_udp_limit(&named), NO_EDNS_UDP_LIMIT);
3500
3501        // ARCOUNT == 0 means there is no additional section to hold an OPT, whatever the trailing
3502        // bytes happen to look like.
3503        let mut no_ar = build_edns_query(0x409, &["example", "com"], 1, 1, 4096);
3504        no_ar[11] = 0;
3505        assert_eq!(client_udp_limit(&no_ar), NO_EDNS_UDP_LIMIT);
3506
3507        // A truncated message falls back to the conservative limit, never a larger one.
3508        let mut chopped = build_edns_query(0x40A, &["example", "com"], 1, 1, 4096);
3509        chopped.truncate(chopped.len() - 8);
3510        assert_eq!(client_udp_limit(&chopped), NO_EDNS_UDP_LIMIT);
3511    }
3512
3513    /// The whole point of the narrow OPT reader, end to end: a stub resolver that advertises 4096
3514    /// **and** sends a DNS cookie is capped at 512, so the 900-byte forwarded reply comes back with
3515    /// `TC` set. A reader that walked the additional section properly would honour the 4096 and
3516    /// leave `TC` clear — which is the answer no Go node on the tailnet would have produced.
3517    #[test]
3518    fn an_opt_record_carrying_options_is_not_honoured() {
3519        let query =
3520            build_edns_query_with_option(0x40B, &["example", "com"], 1, 1, 4096, 10, &[0; 8]);
3521        let mut reply = query.clone();
3522        reply[2] |= 0x80; // QR=1
3523        reply.resize(900, 0xAB);
3524
3525        let out = cap_response(&query, reply, ClientTransport::Udp);
3526        assert_ne!(
3527            out[2] & 0x02,
3528            0,
3529            "an OPT record with options is no EDNS at all upstream: the 512-byte limit applies"
3530        );
3531        assert_eq!(out.len(), 900, "the body is left intact, not chopped");
3532    }
3533
3534    /// An advertised size below 512 is honoured as-is. Go floors nothing: `maxSize = int(ednsSize)`
3535    /// whenever an OPT record is present, and only a request with no OPT record falls back to 512.
3536    #[test]
3537    fn an_advertised_size_below_512_is_not_floored() {
3538        let query = build_edns_query(0x40C, &["example", "com"], 1, 1, 200);
3539        let mut reply = query.clone();
3540        reply[2] |= 0x80; // QR=1
3541        reply.resize(300, 0xAB);
3542
3543        let out = cap_response(&query, reply, ClientTransport::Udp);
3544        assert_ne!(
3545            out[2] & 0x02,
3546            0,
3547            "300 bytes overflows the 200 the client asked for, so TC is set"
3548        );
3549        assert_eq!(out.len(), 300, "the body is left intact, not chopped");
3550    }
3551
3552    /// Upstream runs the size check on answers the resolver builds itself, not only on forwarded
3553    /// ones (`Resolver.Query` calls `checkResponseSizeAndSetTC` right after `respond` succeeds). An
3554    /// authoritative answer is capped at 512 bytes, which says nothing about a client that
3555    /// advertised less than that.
3556    #[test]
3557    fn an_authoritative_answer_over_the_advertised_size_is_marked() {
3558        let view = view_with_peer();
3559        let buf = build_edns_query(0x40D, &["host", "user", "ts", "net"], 1, 1, 20);
3560
3561        let resp = answer(&view, &buf).expect("answers");
3562        assert!(
3563            resp.len() > 20,
3564            "the fixture only works if the answer overflows the advertised 20 bytes"
3565        );
3566
3567        let marked = check_response_size_and_set_tc(&buf, resp.clone(), ClientTransport::Udp);
3568        assert_ne!(
3569            marked[2] & 0x02,
3570            0,
3571            "an answer we composed ourselves can still overflow a small advertised buffer"
3572        );
3573        assert_eq!(marked.len(), resp.len(), "the body is left intact");
3574        assert_eq!(
3575            marked[3..],
3576            resp[3..],
3577            "only the flags byte carrying TC may differ"
3578        );
3579    }
3580
3581    #[test]
3582    fn response_matches_query_rejects_mismatched_question() {
3583        // id + QR match but the echoed question differs (different QNAME) => rejected. This guards
3584        // against an off-path injector that guesses the id but answers a different question.
3585        let query = build_query(0x1234, &["a", "com"], 1, 1);
3586
3587        let mut wrong_question = build_query(0x1234, &["b", "com"], 1, 1);
3588        wrong_question[2] |= 0x80; // QR=1, same id
3589        assert!(
3590            !response_matches_query(&query, &wrong_question),
3591            "different QNAME must be rejected"
3592        );
3593
3594        // A different QTYPE with the same name is also rejected.
3595        let mut wrong_qtype = build_query(0x1234, &["a", "com"], 28, 1);
3596        wrong_qtype[2] |= 0x80;
3597        assert!(
3598            !response_matches_query(&query, &wrong_qtype),
3599            "different QTYPE must be rejected"
3600        );
3601
3602        // The exact echoed question with QR=1 is accepted.
3603        let mut good = query.clone();
3604        good[2] |= 0x80;
3605        assert!(
3606            response_matches_query(&query, &good),
3607            "matching question accepted"
3608        );
3609    }
3610
3611    #[test]
3612    fn suffix_matches_handles_boundaries_and_empty() {
3613        // Exact and label-boundary matches.
3614        assert!(suffix_matches("corp", "corp"));
3615        assert!(suffix_matches("a.corp", "corp"));
3616        assert!(suffix_matches("a.b.corp", "corp"));
3617        // Not a label boundary.
3618        assert!(!suffix_matches("acorp", "corp"));
3619        // Empty suffix never matches (defense-in-depth against `ends_with("")`).
3620        assert!(!suffix_matches("anything.example", ""));
3621        assert!(!suffix_matches("", ""));
3622    }
3623
3624    #[test]
3625    fn empty_search_domain_does_not_capture_everything() {
3626        // Defense-in-depth: an empty search domain must NOT make every name look like a tailnet
3627        // name (which would fail-close legitimate recursive queries / mis-route). With an empty
3628        // suffix present alongside a real resolver, an off-tailnet name still forwards.
3629        let mut view = view_with_routes(
3630            std::collections::BTreeMap::new(),
3631            vec![udp("8.8.8.8:53")],
3632            vec![],
3633        );
3634        view.cfg.search_domains = vec![String::new()];
3635        let buf = build_query(0x400, &["example", "com"], 1, 1);
3636
3637        match decide(&view, &buf).expect("decides") {
3638            Decision::Forward { upstreams, .. } => {
3639                assert_eq!(upstreams, vec!["8.8.8.8:53".parse().unwrap()]);
3640            }
3641            Decision::Reply(_) => {
3642                panic!("empty search domain must not treat every name as tailnet")
3643            }
3644        }
3645    }
3646
3647    #[test]
3648    fn empty_route_suffix_does_not_capture_everything() {
3649        // Defense-in-depth: an empty route suffix must not match every name (which would route all
3650        // queries to that route's upstreams). With an empty-suffix route present, an unrelated name
3651        // still falls through to the global resolver.
3652        let mut routes = std::collections::BTreeMap::new();
3653        routes.insert(String::new(), vec![udp("10.9.9.9:53")]);
3654        let view = view_with_routes(routes, vec![udp("8.8.8.8:53")], vec![]);
3655        let buf = build_query(0x401, &["example", "com"], 1, 1);
3656
3657        match decide(&view, &buf).expect("decides") {
3658            Decision::Forward { upstreams, .. } => {
3659                assert_eq!(
3660                    upstreams,
3661                    vec!["8.8.8.8:53".parse().unwrap()],
3662                    "empty route suffix must not capture; falls through to global"
3663                );
3664            }
3665            Decision::Reply(_) => panic!("expected forward to global resolver"),
3666        }
3667    }
3668
3669    fn udp_exit(addr: &str) -> DnsResolver {
3670        DnsResolver {
3671            transport: ts_control::ResolverTransport::Udp(addr.parse().unwrap()),
3672            use_with_exit_node: true,
3673        }
3674    }
3675
3676    #[test]
3677    fn recursive_forward_is_flagged_route_forward_is_not() {
3678        // A recursive (global/fallback) forward sets `recursive = true` (eligible for DoH
3679        // delegation); a deliberately-configured split-DNS route sets `recursive = false`.
3680        let mut routes = std::collections::BTreeMap::new();
3681        routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
3682        let view = view_with_routes(routes, vec![udp("8.8.8.8:53")], vec![]);
3683
3684        let routed = build_query(0x500, &["api", "corp", "example"], 1, 1);
3685        match decide(&view, &routed).expect("decides") {
3686            Decision::Forward { recursive, .. } => {
3687                assert!(!recursive, "split-DNS route is not a recursive forward")
3688            }
3689            Decision::Reply(_) => panic!("expected route forward"),
3690        }
3691
3692        let global = build_query(0x501, &["example", "com"], 1, 1);
3693        match decide(&view, &global).expect("decides") {
3694            Decision::Forward { recursive, .. } => {
3695                assert!(recursive, "unrouted name is a recursive forward")
3696            }
3697            Decision::Reply(_) => panic!("expected recursive forward"),
3698        }
3699    }
3700
3701    #[test]
3702    fn recursive_plan_keeps_udp_without_exit_node() {
3703        // No active exit node: a recursive forward stays on its default UDP upstreams.
3704        let view = view_with_routes(
3705            std::collections::BTreeMap::new(),
3706            vec![udp("8.8.8.8:53")],
3707            vec![],
3708        );
3709        let default = vec!["8.8.8.8:53".parse().unwrap()];
3710        assert_eq!(
3711            recursive_plan(&view, default.clone()),
3712            RecursivePlan::Udp(default)
3713        );
3714    }
3715
3716    #[test]
3717    fn recursive_plan_delegates_to_doh_with_exit_node() {
3718        // Exit node active, no kept-local resolvers: recursive queries delegate to the exit node's
3719        // DoH endpoint so resolution egresses from the exit node, not this host.
3720        let mut view = view_with_routes(
3721            std::collections::BTreeMap::new(),
3722            vec![udp("8.8.8.8:53")],
3723            vec![],
3724        );
3725        let doh: SocketAddr = "100.64.0.5:8080".parse().unwrap();
3726        view.exit_doh = Some(doh);
3727        assert_eq!(
3728            recursive_plan(&view, vec!["8.8.8.8:53".parse().unwrap()]),
3729            RecursivePlan::Doh(doh)
3730        );
3731    }
3732
3733    #[test]
3734    fn recursive_plan_keeps_use_with_exit_node_resolvers_local() {
3735        // Even with an exit node active, resolvers flagged `use_with_exit_node` stay local (Go keeps
3736        // UseWithExitNode resolvers). The plan forwards to those over UDP, never delegating to DoH.
3737        let mut view = view_with_routes(
3738            std::collections::BTreeMap::new(),
3739            vec![udp_exit("10.0.0.53:53"), udp("8.8.8.8:53")],
3740            vec![],
3741        );
3742        view.exit_doh = Some("100.64.0.5:8080".parse().unwrap());
3743        // The default upstreams the caller computed are irrelevant when kept-local resolvers exist;
3744        // the plan must use the kept-local ones.
3745        assert_eq!(
3746            recursive_plan(&view, vec!["8.8.8.8:53".parse().unwrap()]),
3747            RecursivePlan::Udp(vec!["10.0.0.53:53".parse().unwrap()])
3748        );
3749    }
3750
3751    // --- SOA on authoritative negative answers (RFC 2308) -----------------------------------
3752
3753    /// Read an uncompressed name at `off`, returning it dotted and the offset just past it.
3754    fn read_name(resp: &[u8], mut off: usize) -> (String, usize) {
3755        let mut labels: Vec<String> = Vec::new();
3756        loop {
3757            let len = resp[off] as usize;
3758            assert_eq!(len & 0xC0, 0, "no compression pointer expected here");
3759            off += 1;
3760            if len == 0 {
3761                break;
3762            }
3763            labels.push(String::from_utf8(resp[off..off + len].to_vec()).expect("ascii label"));
3764            off += len;
3765        }
3766        (labels.join("."), off)
3767    }
3768
3769    /// The number of records in a response's authority section (NSCOUNT).
3770    fn nscount(resp: &[u8]) -> u16 {
3771        u16::from_be_bytes([resp[8], resp[9]])
3772    }
3773
3774    /// Walk an answer-less response to its authority section and read the SOA there, returning
3775    /// `(zone, record TTL, SERIAL, MINIMUM)`. `None` when the authority section is empty.
3776    ///
3777    /// Also asserts the record's shape as it goes: TYPE=SOA, CLASS=IN, and MNAME/RNAME both equal
3778    /// the owner name (the placeholders Go writes).
3779    fn parse_soa(resp: &[u8]) -> Option<(String, u32, u32, u32)> {
3780        let (.., ancount) = parse_header(resp);
3781        assert_eq!(ancount, 0, "parse_soa only walks answer-less responses");
3782        if nscount(resp) == 0 {
3783            return None;
3784        }
3785        assert_eq!(nscount(resp), 1, "at most one SOA");
3786
3787        // Question: QNAME then QTYPE + QCLASS.
3788        let (_, off) = read_name(resp, 12);
3789        // Authority record: NAME, TYPE, CLASS, TTL, RDLENGTH, RDATA.
3790        let (zone, off) = read_name(resp, off + 4);
3791        let u16_at = |at: usize| u16::from_be_bytes([resp[at], resp[at + 1]]);
3792        let u32_at = |at: usize| u32::from_be_bytes(resp[at..at + 4].try_into().unwrap());
3793        assert_eq!(u16_at(off), 6, "TYPE = SOA");
3794        assert_eq!(u16_at(off + 2), 1, "CLASS = IN");
3795        let ttl = u32_at(off + 4);
3796        let rdlength = u16_at(off + 8) as usize;
3797
3798        // RDATA: MNAME, RNAME, SERIAL, REFRESH, RETRY, EXPIRE, MINIMUM.
3799        let rdata_start = off + 10;
3800        let (mname, off) = read_name(resp, rdata_start);
3801        let (rname, off) = read_name(resp, off);
3802        assert_eq!(mname, zone, "MNAME is the zone (placeholder)");
3803        assert_eq!(rname, zone, "RNAME is the zone (placeholder)");
3804        let serial = u32_at(off);
3805        let minimum = u32_at(off + 16);
3806        assert_eq!(
3807            off + 20 - rdata_start,
3808            rdlength,
3809            "RDLENGTH covers exactly the SOA fields"
3810        );
3811        assert_eq!(resp.len(), off + 20, "the SOA is the last record");
3812        Some((zone, ttl, serial, minimum))
3813    }
3814
3815    /// Roughly-now, for asserting the SOA SERIAL is a unix timestamp rather than a constant.
3816    fn now_unix() -> u32 {
3817        std::time::SystemTime::now()
3818            .duration_since(std::time::UNIX_EPOCH)
3819            .expect("clock after the epoch")
3820            .as_secs() as u32
3821    }
3822
3823    /// An NXDOMAIN for a name under a tailnet search domain is authoritative, so it carries that
3824    /// search domain's SOA with the 10-second negative TTL. Without it a downstream cache picks its
3825    /// own (much longer) negative lifetime and a node renamed to that name stays unresolvable.
3826    #[test]
3827    fn nxdomain_for_tailnet_name_carries_the_search_domain_soa() {
3828        let view = view_with_peer();
3829        let buf = build_query(0x1111, &["nope", "user", "ts", "net"], 1, 1);
3830
3831        let resp = answer(&view, &buf).expect("answers");
3832        let (_, rcode, ancount) = parse_header(&resp);
3833        assert_eq!(rcode, 3, "NXDOMAIN");
3834        assert_eq!(ancount, 0);
3835
3836        let (zone, ttl, serial, minimum) =
3837            parse_soa(&resp).expect("an SOA in the authority section");
3838        assert_eq!(zone, "user.ts.net", "the search domain containing the name");
3839        assert_eq!(ttl, 10, "negative TTL");
3840        assert_eq!(minimum, 10, "MINIMUM also bounds negative caching");
3841        // The serial is the response time in unix seconds, not a fixed placeholder.
3842        assert!(
3843            serial.abs_diff(now_unix()) < 60,
3844            "SERIAL should be about now, got {serial}"
3845        );
3846    }
3847
3848    /// A NODATA — the name exists but we hold no address of the queried family, which is what an
3849    /// AAAA query for a peer becomes with the IPv6 gate off — is negative too, and takes the SOA.
3850    #[test]
3851    fn nodata_aaaa_for_known_peer_carries_the_soa() {
3852        let view = view_with_peer();
3853        assert!(!view.enable_ipv6, "default gate is off");
3854        let buf = build_query(0x2222, &["host", "user", "ts", "net"], 28, 1);
3855
3856        let resp = answer(&view, &buf).expect("answers");
3857        let (_, rcode, ancount) = parse_header(&resp);
3858        assert_eq!(rcode, 0, "NoError (NODATA)");
3859        assert_eq!(ancount, 0);
3860        let (zone, ttl, _, minimum) = parse_soa(&resp).expect("an SOA in the authority section");
3861        assert_eq!(zone, "user.ts.net");
3862        assert_eq!((ttl, minimum), (10, 10));
3863    }
3864
3865    /// A reverse query for an unmatched IP in the tailnet CGNAT range is authoritatively absent, so
3866    /// it carries the SOA of the reverse zone that covers it — the same per-/16 `in-addr.arpa`
3867    /// chunk real tailscaled advertises, not the search domain.
3868    #[test]
3869    fn cgnat_reverse_miss_carries_the_reverse_zone_soa() {
3870        let view = view_with_peer();
3871        // Reverse name for an unclaimed 100.64.0.0/10 address, least-significant octet first.
3872        let buf = build_query(0x3333, &["9", "0", "64", "100", "in-addr", "arpa"], 12, 1);
3873
3874        let resp = answer(&view, &buf).expect("answers");
3875        let (_, rcode, ancount) = parse_header(&resp);
3876        assert_eq!(rcode, 3, "NXDOMAIN");
3877        assert_eq!(ancount, 0);
3878        let (zone, ttl, _, minimum) = parse_soa(&resp).expect("an SOA in the authority section");
3879        assert_eq!(zone, "64.100.in-addr.arpa", "the CGNAT reverse zone");
3880        assert_eq!((ttl, minimum), (10, 10));
3881    }
3882
3883    /// The exotic-qtype path re-applies the CGNAT reverse guard, and its NXDOMAIN is just as
3884    /// authoritative — so it carries the same reverse-zone SOA the PTR arm does.
3885    #[test]
3886    fn exotic_qtype_cgnat_reverse_nxdomain_carries_the_soa() {
3887        let view = view_with_peer();
3888        // TXT (16) for a CGNAT reverse name.
3889        let buf = build_query(0x4444, &["9", "0", "64", "100", "in-addr", "arpa"], 16, 1);
3890
3891        let resp = answer(&view, &buf).expect("answers");
3892        assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
3893        let (zone, ..) = parse_soa(&resp).expect("an SOA in the authority section");
3894        assert_eq!(zone, "64.100.in-addr.arpa");
3895    }
3896
3897    /// A negative split-DNS route (a route with no resolvers) is Go's `localDomains` verbatim: the
3898    /// NXDOMAIN it produces is authoritative and names the route's own suffix as its zone.
3899    #[test]
3900    fn negative_route_nxdomain_carries_the_route_zone_soa() {
3901        let mut routes = std::collections::BTreeMap::new();
3902        routes.insert("corp.example".to_string(), vec![]);
3903        let view = view_with_routes(routes, vec![], vec![]);
3904        let buf = build_query(0x5555, &["intranet", "corp", "example"], 1, 1);
3905
3906        let resp = answer(&view, &buf).expect("answers");
3907        assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
3908        let (zone, ttl, _, minimum) = parse_soa(&resp).expect("an SOA in the authority section");
3909        assert_eq!(zone, "corp.example");
3910        assert_eq!((ttl, minimum), (10, 10));
3911    }
3912
3913    /// Answers we are NOT authoritative for carry no SOA: a SERVFAIL is a soft failure with nothing
3914    /// to cache, and an `ip6.arpa` NXDOMAIN is this fork's blanket anti-leak refusal, not a claim to
3915    /// serve the IPv6 reverse tree.
3916    #[test]
3917    fn non_authoritative_negative_answers_carry_no_soa() {
3918        let view = view_with_peer();
3919
3920        // Off-tailnet name, no upstream configured => SERVFAIL.
3921        let servfail =
3922            answer(&view, &build_query(0x6, &["example", "com"], 1, 1)).expect("answers");
3923        assert_eq!(parse_header(&servfail).1, 2, "ServFail");
3924        assert_eq!(nscount(&servfail), 0, "SERVFAIL carries no SOA");
3925
3926        // An ip6.arpa reverse name. The exact nibble labels do not matter to the guard.
3927        let mut labels: Vec<&str> = vec!["1"; 32];
3928        labels.push("ip6");
3929        labels.push("arpa");
3930        let ip6 = answer(&view, &build_query(0x7, &labels, 12, 1)).expect("answers");
3931        assert_eq!(parse_header(&ip6).1, 3, "NXDOMAIN");
3932        assert_eq!(nscount(&ip6), 0, "ip6.arpa NXDOMAIN carries no SOA");
3933
3934        // MagicDNS off => REFUSED, which asserts nothing about the name.
3935        let mut off = view_with_peer();
3936        off.cfg.magic_dns = false;
3937        let refused = answer(
3938            &off,
3939            &build_query(0x8, &["host", "user", "ts", "net"], 1, 1),
3940        )
3941        .expect("answers");
3942        assert_eq!(parse_header(&refused).1, 5, "Refused");
3943        assert_eq!(nscount(&refused), 0, "REFUSED carries no SOA");
3944    }
3945
3946    /// A NODATA for a type we simply do not serve on a name we do (TXT on a tailnet name) carries
3947    /// no SOA: Go sets `SOAZone` on a no-data answer only for an A/AAAA/ALL question.
3948    #[test]
3949    fn nodata_for_an_unserved_qtype_carries_no_soa() {
3950        let view = view_with_peer();
3951        let resp = answer(
3952            &view,
3953            &build_query(0x9, &["host", "user", "ts", "net"], 16, 1),
3954        )
3955        .expect("answers");
3956        let (_, rcode, ancount) = parse_header(&resp);
3957        assert_eq!((rcode, ancount), (0, 0), "NODATA");
3958        assert_eq!(nscount(&resp), 0);
3959    }
3960
3961    /// A positive answer has an empty authority section and a 5-second TTL. The short TTL is the
3962    /// positive half of the same argument: the netmap is local and in-memory, so a re-query is
3963    /// nearly free, while a downstream cache would otherwise hide a node rename for the full TTL.
3964    #[test]
3965    fn positive_answer_has_ttl_5_and_no_authority_section() {
3966        let view = view_with_peer();
3967        let resp = answer(
3968            &view,
3969            &build_query(0xA, &["host", "user", "ts", "net"], 1, 1),
3970        )
3971        .expect("answers");
3972        let (_, rcode, ancount) = parse_header(&resp);
3973        assert_eq!((rcode, ancount), (0, 1), "one A record");
3974        assert_eq!(nscount(&resp), 0, "a positive answer claims no zone");
3975        // The single A record's tail is TTL, RDLENGTH, RDATA.
3976        let ttl_at = resp.len() - 10;
3977        let ttl = u32::from_be_bytes(resp[ttl_at..ttl_at + 4].try_into().unwrap());
3978        assert_eq!(ttl, 5, "positive TTL");
3979    }
3980
3981    /// An authoritative negative answer with its SOA attached must still fit the classic 512-byte
3982    /// UDP limit, so the client-limit check leaves TC clear on it for a client that advertised no
3983    /// EDNS buffer. (A client that advertises *less* than 512 is a different case and is marked —
3984    /// see `an_authoritative_answer_over_the_advertised_size_is_marked`.)
3985    #[test]
3986    fn nxdomain_with_soa_stays_within_the_client_udp_limit() {
3987        let view = view_with_peer();
3988        let long = "a".repeat(63);
3989        let buf = build_query(0xB, &[&long, "user", "ts", "net"], 1, 1);
3990
3991        let resp = answer(&view, &buf).expect("answers");
3992        assert_eq!(nscount(&resp), 1, "the SOA fits beside this question");
3993        assert!(resp.len() <= 512, "still one classic UDP datagram");
3994
3995        let marked = check_response_size_and_set_tc(&buf, resp.clone(), ClientTransport::Udp);
3996        assert_eq!(marked, resp, "nothing to mark: an authoritative reply fits");
3997        assert_eq!(
3998            u16::from_be_bytes([marked[2], marked[3]]) & 0x0200,
3999            0,
4000            "TC must stay clear"
4001        );
4002    }
4003
4004    /// When the zone is so long that its SOA no longer fits under the 512-byte cap, the SOA is
4005    /// dropped rather than the answer being truncated: the NXDOMAIN goes back complete, with an
4006    /// empty authority section, TC clear, and still within a client's UDP limit. Losing the SOA
4007    /// only means a resolver falls back to its own negative-cache policy.
4008    #[test]
4009    fn an_soa_that_will_not_fit_is_dropped_and_the_nxdomain_still_answers() {
4010        let long = "a".repeat(63);
4011        let zone = [long.as_str(), long.as_str(), long.as_str()].join(".");
4012        let mut view = view_with_peer();
4013        view.cfg.search_domains = vec![zone.clone()];
4014
4015        let buf = build_query(0xC, &["x", &long, &long, &long], 1, 1);
4016        let resp = answer(&view, &buf).expect("answers");
4017
4018        assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
4019        assert_eq!(nscount(&resp), 0, "the SOA did not fit and was dropped");
4020        assert!(resp.len() <= 512, "response stays within the UDP limit");
4021        let marked = check_response_size_and_set_tc(&buf, resp.clone(), ClientTransport::Udp);
4022        assert_eq!(
4023            u16::from_be_bytes([marked[2], marked[3]]) & 0x0200,
4024            0,
4025            "a dropped SOA must not set TC: the fork cannot serve the TCP retry it would ask for"
4026        );
4027    }
4028
4029    /// The zone is the *longest* authoritative suffix containing the name, so a name under a
4030    /// sub-zone gets the sub-zone's SOA rather than the shorter search domain's.
4031    #[test]
4032    fn the_longest_authoritative_zone_wins() {
4033        let mut routes = std::collections::BTreeMap::new();
4034        routes.insert("sub.user.ts.net".to_string(), vec![]);
4035        let mut view = view_with_routes(routes, vec![], vec![]);
4036        view.cfg.search_domains = vec!["user.ts.net".to_string()];
4037
4038        let buf = build_query(0xD, &["nope", "sub", "user", "ts", "net"], 1, 1);
4039        let resp = answer(&view, &buf).expect("answers");
4040        assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
4041        let (zone, ..) = parse_soa(&resp).expect("an SOA in the authority section");
4042        assert_eq!(zone, "sub.user.ts.net");
4043    }
4044
4045    /// A name we resolved only by search-domain qualification (a short name like `host`) is not
4046    /// itself inside a zone we serve, so its negative answer names no zone — matching Go, whose
4047    /// `authoritativeZoneFor` is given the query name as asked.
4048    #[test]
4049    fn a_short_name_outside_every_zone_gets_no_soa() {
4050        let mut view = view_with_peer();
4051        view.enable_ipv6 = false;
4052        // `host` resolves to the peer via search-domain qualification, and with IPv6 off the AAAA
4053        // is a NODATA — but `host` sits under no zone we serve.
4054        let resp = answer(&view, &build_query(0xE, &["host"], 28, 1)).expect("answers");
4055        let (_, rcode, ancount) = parse_header(&resp);
4056        assert_eq!((rcode, ancount), (0, 0), "NODATA");
4057        assert_eq!(nscount(&resp), 0, "no zone contains a single-label name");
4058    }
4059}