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. For names it is *not* authoritative for, it brings tsnet-style
6//! split-DNS and recursive resolution:
7//!
8//! - **Split DNS** ([`DnsConfig::routes`]): the longest matching suffix route forwards the query
9//!   to one of that route's upstream resolvers. A route with an **empty** upstream list is a
10//!   negative route — names under it are `NXDOMAIN` (Go keeps them on the built-in resolver; for
11//!   us that means fail-closed unless an overlay/extra record matched first).
12//! - **Recursive** ([`DnsConfig::fallback_resolvers`] / [`DnsConfig::resolvers`]): names matching
13//!   no route are forwarded to the fallback resolvers, else the global resolvers.
14//! - **Fail closed**: if no route and no resolver is configured, an unknown name is `NXDOMAIN`.
15//!
16//! Anti-leak / IPv6-off posture: upstream forwarding binds `0.0.0.0:0` (UDP, IPv4 only) and never
17//! opens an IPv6 socket. AAAA handling is gated on [`DnsView::enable_ipv6`] (default off): with the
18//! gate OFF an AAAA query for a tailnet/overlay/self name returns NoError with an empty answer
19//! (NODATA) rather than the overlay v6 address — answering a v6 the IPv4-only client can't route
20//! would only create dead connections and a fingerprint. With the gate ON, AAAA is answered from
21//! overlay data (the v6 overlay addr), as historically. AAAA for tailnet names is never forwarded
22//! to a recursive upstream regardless of the gate.
23//!
24//! - MagicDNS disabled (`dns_config == None` or `magic_dns == false`), OR the node does not accept
25//!   the tailnet DNS config ([`DnsView::accept_dns`] is `false`, i.e. `--accept-dns` / `CorpDNS`
26//!   off) => `REFUSED` for every query (the responder serves nothing, mirroring Go applying an empty
27//!   `dns.Config` when `CorpDNS` is off).
28//! - A qtype/class we don't serve authoritatively (anything but IN-class A/AAAA/PTR — TXT, SRV, MX,
29//!   HTTPS/SVCB, a CHAOS-class query, …) => NODATA (empty NOERROR) for a tailnet-authoritative name,
30//!   forwarded verbatim to upstream for an off-tailnet name — exactly like Go's resolver, NOT
31//!   `REFUSED` (a stub reads REFUSED as "won't serve me" and abandons the resolver). Tailnet reverse
32//!   zones (CGNAT `in-addr.arpa` / any `ip6.arpa`) still fail closed to NXDOMAIN for every qtype
33//!   (never forwarded — anti-leak).
34//! - A **negative** answer this node is authoritative for — an NXDOMAIN for a name inside a zone we
35//!   serve (a tailnet search domain, a negative split-DNS route, or the CGNAT reverse zone), or a
36//!   NODATA for such a name — carries that zone's `SOA` in the authority section, advertising a
37//!   10-second negative-caching bound (RFC 2308). Without one, macOS `mDNSResponder` keeps an
38//!   SOA-less negative answer on its own schedule, so a name queried shortly *before* a node was
39//!   renamed to it stays unresolvable until something flushes the cache. Positive answers carry a
40//!   5-second TTL for the same reason in the other direction. `SERVFAIL`, `REFUSED` and the blanket
41//!   `ip6.arpa` refusal claim no zone and carry no SOA.
42//! - Malformed query => dropped (no response).
43//! - A **forwarded** reply larger than the UDP payload size the query advertised — its EDNS(0) OPT
44//!   record, or 512 bytes when it carried none (RFC 1035) — comes back with the `TC` (truncated)
45//!   bit set and its body intact, so the stub resolver knows to retry over TCP
46//!   ([`set_tc_if_over_client_limit`]). The query is forwarded verbatim, so this is what catches an
47//!   upstream that ignores the size its requestor asked for.
48
49use std::{
50    net::{IpAddr, Ipv4Addr, SocketAddr},
51    sync::Arc,
52    time::Duration,
53};
54
55use kameo::{
56    actor::ActorRef,
57    message::{Context, Message},
58};
59use netstack::{CreateSocket, netcore::Channel};
60use tokio::{
61    sync::{Semaphore, watch},
62    task::JoinSet,
63    time::timeout,
64};
65use ts_control::{DnsConfig, DnsResolver, Node};
66use ts_dns_wire::{Name, QType, RData, Rcode, SoaZone, decode_query, encode_response};
67
68use crate::{
69    Error,
70    env::Env,
71    peer_tracker::{PeerDb, PeerState},
72};
73
74/// How long to wait for an upstream resolver to answer a forwarded query before giving up.
75const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(5);
76/// Cap on concurrent in-flight forwarded queries on the local `100.100.100.100:53` responder.
77///
78/// Each forward is spawned onto a task that holds an overlay UDP socket until the upstream answers
79/// or [`UPSTREAM_TIMEOUT`] elapses. Without a cap, a local/tailnet client spraying distinct
80/// forwardable names opens unbounded concurrent overlay sockets + tasks (a resource-exhaustion DoS
81/// on a slow/black-holed upstream, since each lingers for the full timeout). Bound it the same way
82/// the peerAPI DoH server bounds its request handlers ([`crate::peerapi`]'s `MAX_INFLIGHT`): acquire
83/// a permit before spawning and drop the query fail-closed when saturated. A dropped DNS query is a
84/// benign outcome — the stub resolver simply retries or times out — and Go's resolver likewise
85/// bounds outstanding forwards rather than spawning without limit.
86const MAX_INFLIGHT_FORWARDS: usize = 512;
87/// Cap on how much of a forwarded upstream response we relay back to the stub resolver (a single
88/// UDP datagram).
89///
90/// The value matches Go's forwarder read buffer (`maxResponseBytes`, ~4 KiB), but *where it applies
91/// differs*: here it is not a read bound and it does not bound memory. [`forward_query`] reads with
92/// `recv_from_bytes`, which issues `Recv { max_len: None }`, so the netstack has already copied the
93/// whole queued datagram out before [`cap_response`] sees it. What bounds the read is the netstack
94/// UDP socket's receive ring — `netcore::Config::udp_buffer_size`, 4 KiB by default and not
95/// overridden by `ts_runtime` — and smoltcp drops a datagram larger than that ring at enqueue rather
96/// than delivering a chopped one. With the ring and this cap both at 4 KiB, [`cap_response`]'s
97/// truncate-and-set-`TC` branch is therefore defensive: no datagram this socket can deliver reaches
98/// it (pinned by `cap_is_a_relay_bound_not_the_read_bound`).
99///
100/// The client's query is forwarded verbatim, so a client advertising a large EDNS UDP size can
101/// elicit a legitimately large (1300–4096 byte) UDP answer (big TXT sets, DNSSEC, many-record
102/// round-robins). Capping at the old 1232 truncated those and set TC, forcing a TCP retry this
103/// fork's UDP-only forwarder can't serve — so the large answer became unreachable. 4096 relays them
104/// intact.
105const MAX_UPSTREAM_RESPONSE: usize = 4096;
106
107/// The MagicDNS service IP. The netstack interface owns this address, so a `udp_bind` here
108/// receives the tailnet's DNS traffic.
109const MAGIC_DNS_IP: Ipv4Addr = Ipv4Addr::new(100, 100, 100, 100);
110/// The DNS service port.
111const MAGIC_DNS_PORT: u16 = 53;
112
113/// The latest view the answer loop resolves queries against.
114///
115/// Updated by the actor's message handlers (from control `StateUpdate` and peer `PeerState`
116/// updates) and read fresh by the answer loop for every packet.
117#[derive(Clone, Default)]
118pub(crate) struct DnsView {
119    /// The DNS configuration. `magic_dns == false` (the default) means serve nothing.
120    pub(crate) cfg: DnsConfig,
121    /// The current peer database, if we've seen a peer update.
122    pub(crate) peers: Option<Arc<PeerDb>>,
123    /// This node, if we've seen a self-node update.
124    pub(crate) self_node: Option<Node>,
125    /// The peerAPI DoH socket address of the currently-selected exit node, if one is active and can
126    /// proxy DNS ([`Node::peerapi_doh_addr`]). When set, the MagicDNS *client* serve loop delegates
127    /// recursive resolution to this address over the overlay instead of forwarding to the locally
128    /// configured upstream resolvers — so recursive DNS egresses from the exit node, not this host.
129    ///
130    /// Only consumed by the local MagicDNS responder's serve loop (the client side). The peerAPI
131    /// DoH *server* shares this same view but ignores this field: an exit-node DNS proxy resolves
132    /// recursively itself (gated by `forward_exit_egress`), it never re-delegates to its own exit
133    /// node. `None` means no active exit node / no DoH delegation — recursion stays local.
134    pub(crate) exit_doh: Option<SocketAddr>,
135    /// Whether IPv6 is enabled on the tailnet overlay (from [`Env::enable_ipv6`], default `false`).
136    ///
137    /// Governs the AAAA answer path only: with the gate OFF (default) an AAAA query for a
138    /// tailnet/overlay/self name is answered NoError-with-empty-answer (NODATA) instead of the
139    /// overlay v6 address; with it ON, AAAA is answered from overlay data as historically. Set once
140    /// from the runtime `Env` when the actor starts; never changes for the life of the runtime.
141    pub(crate) enable_ipv6: bool,
142    /// Whether the tailnet's DNS configuration is accepted (`--accept-dns` / `CorpDNS`, from
143    /// [`Env::accept_dns`]). When `false`, [`decide`] refuses every query (the responder serves
144    /// nothing), mirroring Go applying an empty `dns.Config` when `CorpDNS` is off — so a node can
145    /// join for connectivity without taking over DNS.
146    ///
147    /// Unlike [`enable_ipv6`](DnsView::enable_ipv6) (snapshotted once at actor spawn), this is
148    /// runtime-settable via `Device::set_accept_dns`, so it is re-read from the live
149    /// [`Env::accept_dns`] cell on **every** view rebuild (the `StateUpdate` and `PeerState`
150    /// handlers), not just at spawn — otherwise a runtime toggle would never reach the served view.
151    pub(crate) accept_dns: bool,
152}
153
154impl DnsView {
155    /// Find the node (peer or self) that answers to `name`, case/dot-insensitively.
156    fn node_by_name(&self, name: &str) -> Option<Node> {
157        if let Some(node) = self
158            .peers
159            .as_ref()
160            .and_then(|p| p.get(&name).map(|(_, n)| n.clone()))
161        {
162            return Some(node);
163        }
164
165        self.self_node
166            .as_ref()
167            .filter(|n| n.matches_name(name))
168            .cloned()
169    }
170
171    /// Resolve `canon` to an answer address of the requested family. A tailnet peer/self match
172    /// wins first — tried as written and then qualified by each tailnet search domain (so a
173    /// short/partially-qualified name like `host` or `host.user` still resolves to
174    /// `host.user.ts.net`). Failing that, a control-pushed [`ExtraRecord`] of the matching family
175    /// answers, matched as a fully-qualified name only (no search-domain expansion — like Go tsnet,
176    /// ExtraRecords are authoritative FQDN entries, not subject to client search-list qualification).
177    /// Still fail-closed: only ever resolves to a known tailnet peer/self or an explicitly
178    /// control-pushed static record — never anything else.
179    fn resolve_addr(&self, canon: &str, want_v4: bool) -> Option<IpAddr> {
180        let addr_of = |node: Node| -> IpAddr {
181            if want_v4 {
182                IpAddr::from(node.tailnet_address.ipv4.addr())
183            } else {
184                IpAddr::from(node.tailnet_address.ipv6.addr())
185            }
186        };
187
188        if let Some(node) = self.node_by_name(canon) {
189            return Some(addr_of(node));
190        }
191        for suffix in &self.cfg.search_domains {
192            if let Some(node) = self.node_by_name(&format!("{canon}.{suffix}")) {
193                return Some(addr_of(node));
194            }
195        }
196
197        // Control-pushed static records match the fully-qualified query name only.
198        self.cfg.extra_records.iter().find_map(|rec| {
199            let family_ok = matches!(
200                (rec.addr, want_v4),
201                (IpAddr::V4(_), true) | (IpAddr::V6(_), false)
202            );
203            (rec.name == canon && family_ok).then_some(rec.addr)
204        })
205    }
206
207    /// Find the node (peer or self) that owns the tailnet IP `ip`.
208    fn node_by_ip(&self, ip: IpAddr) -> Option<Node> {
209        if let Some(node) = self
210            .peers
211            .as_ref()
212            .and_then(|p| p.get(&ip).map(|(_, n)| n.clone()))
213        {
214            return Some(node);
215        }
216
217        self.self_node
218            .as_ref()
219            .filter(|n| {
220                IpAddr::from(n.tailnet_address.ipv4.addr()) == ip
221                    || IpAddr::from(n.tailnet_address.ipv6.addr()) == ip
222            })
223            .cloned()
224    }
225
226    /// Decide how to resolve a non-overlay `name` against the split-DNS routes and recursive
227    /// resolvers, returning the upstreams to forward to.
228    ///
229    /// Longest-suffix wins among [`DnsConfig::routes`]: a route's suffix matches `name` if `name`
230    /// equals it or ends with `.suffix`. A matched route with a non-empty upstream list forwards
231    /// there; a matched route with an **empty** list is a negative route ([`Upstreams::Block`] =>
232    /// NXDOMAIN). With no route match, [`DnsConfig::fallback_resolvers`] (preferred) or
233    /// [`DnsConfig::resolvers`] resolve recursively; if neither is configured we stay fail-closed
234    /// ([`Upstreams::None`] => NXDOMAIN).
235    fn route_for(&self, name: &str) -> Upstreams<'_> {
236        let mut best: Option<(&str, &Vec<DnsResolver>)> = None;
237        for (suffix, upstreams) in &self.cfg.routes {
238            if suffix_matches(name, suffix) && best.is_none_or(|(b, _)| suffix.len() > b.len()) {
239                best = Some((suffix.as_str(), upstreams));
240            }
241        }
242
243        if let Some((_, upstreams)) = best {
244            return if upstreams.is_empty() {
245                Upstreams::Block
246            } else {
247                // A deliberately-configured split-DNS route: not eligible for exit-node DoH
248                // delegation — these upstreams (e.g. an internal resolver reachable over a subnet
249                // route) must keep receiving the query directly.
250                Upstreams::Route(upstreams)
251            };
252        }
253
254        if !self.cfg.fallback_resolvers.is_empty() {
255            return Upstreams::Recursive(&self.cfg.fallback_resolvers);
256        }
257        if !self.cfg.resolvers.is_empty() {
258            return Upstreams::Recursive(&self.cfg.resolvers);
259        }
260        Upstreams::None
261    }
262}
263
264/// The upstreams a non-overlay query should be forwarded to (or why it should not be forwarded).
265enum Upstreams<'a> {
266    /// A split-DNS route matched: forward to these route-specific upstreams (never DoH-delegated).
267    Route(&'a [DnsResolver]),
268    /// No route matched: forward to these recursive (fallback/global) resolvers. Eligible for
269    /// exit-node DoH delegation in the client serve loop.
270    Recursive(&'a [DnsResolver]),
271    /// A negative split-DNS route matched: do not resolve (NXDOMAIN). The route's suffix is a zone
272    /// this node is authoritative for — Go's `localDomains` is exactly the set of routes configured
273    /// with no resolvers — so [`authoritative_zone_for`] finds it again when naming the negative
274    /// answer's SOA zone.
275    Block,
276    /// No route and no resolver configured: fail closed (NXDOMAIN).
277    None,
278}
279
280/// What the (sync) decision step concluded for a query: either a complete response to send back,
281/// or a request to forward the original query to an upstream resolver.
282pub(crate) enum Decision {
283    /// A fully-formed response is ready to send.
284    Reply(Vec<u8>),
285    /// Forward the original query datagram to one of these upstream UDP resolvers; on success
286    /// relay the upstream answer, on failure/timeout answer with the prebuilt `servfail` buffer
287    /// (an off-tailnet name we failed to forward is a soft failure, not a cacheable non-existence —
288    /// Go forwarder.go:1297-1307).
289    Forward {
290        /// UDP upstreams to try, in order.
291        upstreams: Vec<SocketAddr>,
292        /// The original query bytes to forward verbatim.
293        query: Vec<u8>,
294        /// Fallback SERVFAIL response if every upstream fails or times out.
295        servfail: Vec<u8>,
296        /// Whether this is a *recursive* (catch-all fallback/global resolver) forward, as opposed
297        /// to a deliberately-configured split-DNS route. Only recursive forwards are eligible for
298        /// exit-node DoH delegation in the client serve loop (see [`DnsView::exit_doh`]); split-DNS
299        /// routes always stay on their configured upstreams (typically subnet-reachable internal
300        /// resolvers). The peerAPI DoH *server* ignores this flag entirely.
301        recursive: bool,
302    },
303}
304
305/// Whether `name` is `suffix` or sits under it at a label boundary: `"a.corp"` matches `"corp"`,
306/// `"acorp"` does not. An **empty** suffix never matches (defense-in-depth: an empty suffix would
307/// otherwise make `ends_with("")` match every name and either over-route or treat everything as a
308/// tailnet name — both leak-prone).
309fn suffix_matches(name: &str, suffix: &str) -> bool {
310    if suffix.is_empty() {
311        return false;
312    }
313    name == suffix
314        || (name.len() > suffix.len()
315            && name.ends_with(suffix)
316            && name.as_bytes()[name.len() - suffix.len() - 1] == b'.')
317}
318
319/// Returns `true` if `name` falls under one of the tailnet search domains. Such names are
320/// authoritative MagicDNS names and are NEVER forwarded to an upstream resolver — anti-leak: a
321/// tailnet name (and the fact that it was queried) must not escape to a third-party resolver.
322fn is_tailnet_name(view: &DnsView, name: &str) -> bool {
323    view.cfg
324        .search_domains
325        .iter()
326        .any(|suffix| suffix_matches(name, suffix))
327}
328
329/// Whether `name` is an IPv6 reverse-DNS (`PTR`) name (ends in `ip6.arpa`). This fork is IPv4-only
330/// on the tailnet; an IPv6 reverse lookup must NEVER be forwarded to a third-party resolver
331/// (anti-leak: it would reveal that a tailnet v6 address — e.g. a ULA `fd7a:…` — was probed). All
332/// such queries fail closed to NXDOMAIN.
333fn is_ip6_arpa(name: &str) -> bool {
334    suffix_matches(name, "ip6.arpa")
335}
336
337/// Whether `ip` is in the Tailscale CGNAT range `100.64.0.0/10` (RFC 6598, the tailnet IPv4 space).
338/// Reverse (`PTR`) queries for these addresses are authoritative to MagicDNS: if no peer owns the
339/// IP we fail closed to NXDOMAIN rather than forwarding the probe to a third-party resolver.
340fn is_tailnet_cgnat(ip: Ipv4Addr) -> bool {
341    let o = ip.octets();
342    o[0] == 100 && (64..=127).contains(&o[1])
343}
344
345/// The zone this node is authoritative for that contains `canon`, or `None` when it is not
346/// authoritative for the name.
347///
348/// Mirrors Go `net/dns/resolver/tsdns.go` `authoritativeZoneFor`, which scans `Resolver.localDomains`.
349/// Go's `localDomains` is exactly the set of control-pushed routes with **no** resolvers
350/// (`net/dns/manager.go` `compileConfig`), so the equivalent set here is the union of:
351///
352/// - the tailnet search domains — what [`is_tailnet_name`] tests, and the zone a tailnet-suffix
353///   NXDOMAIN belongs to;
354/// - the negative split-DNS routes (a route with an empty upstream list), the literal shape of
355///   Go's `localDomains`;
356/// - the CGNAT reverse zone `<b>.100.in-addr.arpa` covering a `100.64.0.0/10` reverse name.
357///   Synthesized rather than read from the routes: this fork's reverse guard is structural
358///   ([`is_tailnet_cgnat`]) and holds whether or not control pushed the matching route, and the
359///   zone it names is the same per-/16 chunk real tailscaled advertises.
360///
361/// `ip6.arpa` is deliberately absent. This fork NXDOMAINs *every* `ip6.arpa` name as an anti-leak
362/// measure ([`is_ip6_arpa`]) rather than because it serves that zone, and an SOA naming `ip6.arpa`
363/// would claim authority over the whole IPv6 reverse tree — a claim we do not have and one that
364/// would have a client negative-cache far more than this node answers for.
365///
366/// The longest match wins. Go returns the first match from an unordered slice; longest gives the
367/// same answer whenever the zones nest (the usual case) and is a defensible tie-break when they
368/// do not.
369fn authoritative_zone_for(view: &DnsView, name: &Name, canon: &str) -> Option<String> {
370    if let Some(octets) = name.ptr_to_ipv4() {
371        let v4: Ipv4Addr = octets.into();
372        if is_tailnet_cgnat(v4) {
373            return Some(format!("{}.100.in-addr.arpa", v4.octets()[1]));
374        }
375    }
376
377    view.cfg
378        .search_domains
379        .iter()
380        .map(String::as_str)
381        .chain(
382            view.cfg
383                .routes
384                .iter()
385                .filter(|(_, upstreams)| upstreams.is_empty())
386                .map(|(suffix, _)| suffix.as_str()),
387        )
388        .filter(|zone| suffix_matches(canon, zone))
389        .max_by_key(|zone| zone.len())
390        .map(str::to_owned)
391}
392
393/// The SOA record to attach to an authoritative **negative** answer (NXDOMAIN, or NODATA for a
394/// name we serve), or `None` when this node is not authoritative for a zone containing the name.
395///
396/// Without it, a downstream cache decides for itself how long to remember the nonexistence: macOS
397/// `mDNSResponder` holds an SOA-less negative answer for a long time, so a name queried shortly
398/// *before* a node was renamed to it keeps failing until something flushes the cache. The SOA
399/// bounds that at 10 seconds (RFC 2308), which is what Go's resolver advertises.
400fn soa_for(view: &DnsView, name: &Name, canon: &str) -> Option<SoaZone> {
401    let zone = authoritative_zone_for(view, name, canon)?;
402    Some(SoaZone {
403        zone: Name(zone.split('.').map(str::to_owned).collect()),
404        serial: soa_serial(),
405    })
406}
407
408/// The SOA SERIAL to publish: the response time in unix seconds.
409///
410/// A serial is meant to change only when the zone data does, but nothing consumes ours — this node
411/// has no secondaries and serves no zone transfers — so Go uses the current time and so do we. It
412/// is monotonic, cheap, and fits in a `u32` until 2106. A clock before the epoch yields 0 rather
413/// than panicking; the value carries no meaning either way.
414fn soa_serial() -> u32 {
415    std::time::SystemTime::now()
416        .duration_since(std::time::UNIX_EPOCH)
417        .map_or(0, |since| since.as_secs() as u32)
418}
419
420/// Decide what to do with a single DNS query against `view`: either a complete response is ready
421/// ([`Decision::Reply`]), the query should be forwarded to upstream resolvers
422/// ([`Decision::Forward`]), or the packet should be dropped without answering (`None`).
423///
424/// Factored out of the socket loop so it can be unit-tested without a netstack: it does no I/O and
425/// reads no state but `view` and the wall clock (the SOA SERIAL of a negative answer, which nothing
426/// consumes — see [`soa_serial`]). It never panics and fails closed: an unknown, unroutable, or
427/// tailnet-suffix name resolves to NXDOMAIN rather than leaking to an upstream resolver.
428pub(crate) fn decide(view: &DnsView, buf: &[u8]) -> Option<Decision> {
429    // Malformed / non-query input is dropped: we never answer something we can't parse.
430    let query = decode_query(buf).ok()?;
431    let q = &query.question;
432    let id = query.id;
433    // Echo the query's RD bit (and set RA when set) on the response — Go derives the response header
434    // from the query header.
435    let rd = query.recursion_desired;
436
437    let reply = |rcode, answers: &[RData]| {
438        Decision::Reply(encode_response(id, q, rd, rcode, answers, None))
439    };
440    // A negative answer (NXDOMAIN, or NODATA) for a name inside a zone we serve carries that zone's
441    // SOA in the authority section, which bounds how long a downstream resolver may cache the
442    // nonexistence (RFC 2308). `soa_for` returns `None` when we are not authoritative for the name,
443    // in which case this is exactly `reply`.
444    let reply_negative = |rcode, canon: &str| {
445        Decision::Reply(encode_response(
446            id,
447            q,
448            rd,
449            rcode,
450            &[],
451            soa_for(view, &q.name, canon).as_ref(),
452        ))
453    };
454
455    // Fail closed: MagicDNS off, or the node doesn't accept the tailnet's DNS config
456    // (`--accept-dns` / `CorpDNS` is false) => serve nothing. The `accept_dns` gate mirrors Go
457    // applying an empty `dns.Config` when `CorpDNS` is off: the node ignores the control-pushed DNS
458    // config and refuses every query. This one read site covers the netstack responder, the peerAPI
459    // DoH server that shares the view, and (via `tun_actor::plan_intercept`) the TUN query path.
460    if !view.cfg.magic_dns || !view.accept_dns {
461        return Some(reply(Rcode::Refused, &[]));
462    }
463
464    let canon = q.name.to_canon();
465
466    // We only serve the internet (IN) class authoritatively. A non-IN class (CHAOS, HESIOD, the
467    // ANY/255 class, ...) is NOT refused outright: Go's local resolver does no class check and
468    // forwards such a query like any other name. Treat it as an unsupported authoritative type —
469    // NODATA for a tailnet name, forward for an off-tailnet name — so a `CH TXT version.bind`
470    // diagnostic or a `qclass=ANY` probe reaches upstream instead of getting REFUSED.
471    const CLASS_IN: u16 = 1;
472    if q.qclass != CLASS_IN {
473        return Some(forward_or_nodata(view, &canon, buf, id, q, rd));
474    }
475
476    Some(match &q.qtype {
477        QType::A => match view.resolve_addr(&canon, true) {
478            Some(IpAddr::V4(v4)) => reply(Rcode::NoError, &[RData::A(v4.octets())]),
479            // No overlay/extra-record answer: try split-DNS / recursive upstreams.
480            _ => forward_or_nxdomain(view, &canon, buf, id, q, rd),
481        },
482        QType::Aaaa => match view.resolve_addr(&canon, false) {
483            // A tailnet/overlay/self (or extra-record) AAAA match. Gate on IPv6: with IPv6 OFF
484            // (default) the client is IPv4-only, so answering with the overlay v6 address would
485            // only hand out an unroutable address — dead connections plus a fingerprint. Return
486            // NoError with an empty answer (NODATA) instead. With the gate ON, answer from overlay
487            // data as historically. We never forward this name to a recursive upstream either way:
488            // a positive overlay match is authoritative.
489            Some(IpAddr::V6(v6)) if view.enable_ipv6 => {
490                reply(Rcode::NoError, &[RData::Aaaa(v6.octets())])
491            }
492            // NODATA: the name exists but we hold no address of the queried family for it, so it
493            // takes the SOA — Go sets `SOAZone` on exactly this case (`rcode == RCodeSuccess &&
494            // !ip.IsValid()` for an A/AAAA/ALL question).
495            Some(IpAddr::V6(_)) => reply_negative(Rcode::NoError, &canon),
496            // No overlay/extra-record answer: split-DNS / recursive upstreams (off-tailnet names);
497            // tailnet names fail closed to NXDOMAIN inside `forward_or_nxdomain`.
498            _ => forward_or_nxdomain(view, &canon, buf, id, q, rd),
499        },
500        QType::Ptr => match q.name.ptr_to_ipv4() {
501            Some(octets) => {
502                let v4: Ipv4Addr = octets.into();
503                let ip = IpAddr::V4(v4);
504                match view.node_by_ip(ip) {
505                    Some(node) => {
506                        let fqdn = node.fqdn(false);
507                        let labels: Vec<String> = fqdn.split('.').map(str::to_owned).collect();
508                        reply(Rcode::NoError, &[RData::Ptr(Name(labels))])
509                    }
510                    // Anti-leak: a reverse query for an IP in the tailnet CGNAT range
511                    // (100.64.0.0/10) that misses the peer set is authoritative-but-unknown; fail
512                    // closed to NXDOMAIN rather than leaking the probed tailnet IP upstream. Only
513                    // genuinely off-tailnet reverse queries are forwarded.
514                    None if is_tailnet_cgnat(v4) => reply_negative(Rcode::NxDomain, &canon),
515                    None => forward_or_nxdomain(view, &canon, buf, id, q, rd),
516                }
517            }
518            // Anti-leak / IPv4-only-tailnet: an IPv6 reverse (`ip6.arpa`) PTR must never be
519            // forwarded — relaying it would reveal that a tailnet v6 address (e.g. a ULA `fd7a:…`)
520            // was probed. Fail closed to NXDOMAIN, exactly like the IPv4 CGNAT guard above. No SOA:
521            // this blanket refusal is anti-leak, not a claim to serve `ip6.arpa` (see
522            // [`authoritative_zone_for`]).
523            None if is_ip6_arpa(&canon) => reply(Rcode::NxDomain, &[]),
524            None => forward_or_nxdomain(view, &canon, buf, id, q, rd),
525        },
526        // Anything else (TXT, SRV, MX, HTTPS/SVCB, CNAME, ...): we hold no authoritative record of
527        // that type, so — like Go's resolver — forward it to upstream for an off-tailnet name and
528        // return NODATA (empty NOERROR) for a tailnet-authoritative name. NOT REFUSED: a stub reads
529        // REFUSED as "this server won't serve me" and abandons the resolver, which would break
530        // ordinary client lookups (notably HTTPS/SVCB type 65, issued routinely by browsers for
531        // HTTP/3 + ECH) for the same off-tailnet names whose A/AAAA already forward.
532        QType::Other(_) => forward_or_nodata(view, &canon, buf, id, q, rd),
533    })
534}
535
536/// For a name with no overlay answer, consult the split-DNS routes + recursive resolvers and
537/// either forward (to UDP upstreams), answer authoritatively absent (NXDOMAIN), or fail soft
538/// (SERVFAIL) when an off-tailnet name simply can't be forwarded.
539///
540/// Rcode parity with Go's resolver (`net/dns/resolver/tsdns.go` resolution order + `forwarder.go`):
541/// - A **tailnet-authoritative** name (search-domain suffix) or a **negative split-DNS route**
542///   (`Upstreams::Block` — a route configured with no resolvers, which Go answers authoritatively
543///   from Hosts, so an unmatched name under it is authoritatively absent) → **NXDOMAIN**.
544/// - An **off-tailnet** name we cannot forward — no route and no resolver configured
545///   (`Upstreams::None`), or a route whose resolvers are all filtered out (IPv6-only under the
546///   IPv4-only egress) → **SERVFAIL**, matching Go forwarder.go:1207 ("no upstream resolvers set,
547///   returning SERVFAIL"). A cacheable NXDOMAIN on a transient/structural inability to forward would
548///   make a downstream stub cache the *non-existence* of a real name; SERVFAIL is a soft failure the
549///   stub retries.
550///
551/// Anti-leak: a tailnet-suffix name is authoritative and is never forwarded — neither the name nor
552/// the query leaks to a third-party resolver. (The CGNAT `in-addr.arpa` / `ip6.arpa` reverse-zone
553/// NXDOMAIN guards live in the PTR arm of [`decide`] and are likewise unaffected.)
554fn forward_or_nxdomain(
555    view: &DnsView,
556    canon: &str,
557    buf: &[u8],
558    id: u16,
559    q: &ts_dns_wire::Question,
560    rd: bool,
561) -> Decision {
562    // NXDOMAIN for authoritative-absent names; SERVFAIL for an off-tailnet name we can't forward.
563    // An authoritative NXDOMAIN carries the zone's SOA so a downstream cache bounds how long it
564    // remembers the nonexistence (RFC 2308); a SERVFAIL never does — it asserts nothing to cache,
565    // and we are not authoritative for the name we failed to forward.
566    let nxdomain = |canon: &str| {
567        encode_response(
568            id,
569            q,
570            rd,
571            Rcode::NxDomain,
572            &[],
573            soa_for(view, &q.name, canon).as_ref(),
574        )
575    };
576    let servfail = encode_response(id, q, rd, Rcode::ServFail, &[], None);
577
578    if is_tailnet_name(view, canon) {
579        return Decision::Reply(nxdomain(canon));
580    }
581
582    let (resolvers, recursive) = match view.route_for(canon) {
583        Upstreams::Route(resolvers) => (resolvers, false),
584        Upstreams::Recursive(resolvers) => (resolvers, true),
585        // A negative split-DNS route is authoritative-absent (Go answers it from Hosts): NXDOMAIN.
586        // Go's `localDomains` *is* this route set, so the route's own suffix names the zone.
587        Upstreams::Block => return Decision::Reply(nxdomain(canon)),
588        // No route and no resolver: an off-tailnet name we have nowhere to forward — SERVFAIL, not
589        // a cacheable non-existence (Go forwarder.go:1207).
590        Upstreams::None => return Decision::Reply(servfail),
591    };
592
593    let upstreams: Vec<SocketAddr> = resolvers
594        .iter()
595        .map(DnsResolver::udp_addr)
596        // Anti-leak / IPv6-off: only forward over IPv4 upstreams; never open a v6 socket.
597        .filter(SocketAddr::is_ipv4)
598        .collect();
599    if upstreams.is_empty() {
600        // We had a route but every resolver was filtered out (IPv6-only): we cannot forward this
601        // off-tailnet name, so soft-fail rather than assert non-existence.
602        Decision::Reply(servfail)
603    } else {
604        Decision::Forward {
605            upstreams,
606            query: buf.to_vec(),
607            // All upstreams failing at runtime is also an inability to forward, not a non-existence
608            // (Go forwarder.go:1297-1307): hand the forwarder a SERVFAIL fallback, not NXDOMAIN.
609            servfail,
610            recursive,
611        }
612    }
613}
614
615/// The DNS query types Go's resolver explicitly leaves unimplemented for a tailnet-authoritative
616/// name, answering `RCodeNotImplemented` (NOTIMP) rather than NODATA (`net/dns/resolver/tsdns.go`
617/// `resolveLocal`: `case dns.TypeNS, dns.TypeSOA, dns.TypeAXFR, dns.TypeHINFO`). The numeric type
618/// codes: NS=2, SOA=6, HINFO=13, AXFR=252.
619fn is_unimplemented_tailnet_qtype(qtype: &ts_dns_wire::QType) -> bool {
620    matches!(qtype, ts_dns_wire::QType::Other(2 | 6 | 13 | 252))
621}
622
623/// For a query whose *qtype/qclass* we don't serve authoritatively (anything other than an IN-class
624/// A/AAAA/PTR — e.g. TXT, SRV, MX, HTTPS/SVCB, or a CHAOS-class query): forward it to upstream like
625/// any other name, but for a tailnet-authoritative name return an empty NOERROR (NODATA) instead of
626/// NXDOMAIN — except the NS/SOA/HINFO/AXFR types Go answers NOTIMP for
627/// ([`is_unimplemented_tailnet_qtype`]).
628///
629/// This mirrors Go's resolver: an authoritative name with no record of the requested type returns
630/// `RCodeSuccess` with no answers ("the name exists, but no records of that type"), NOT NXDOMAIN and
631/// NOT REFUSED; a non-authoritative name is forwarded verbatim regardless of qtype. The fork
632/// previously REFUSED every non-A/AAAA/PTR qtype (and every non-IN class) for *all* names, which a
633/// stub resolver reads as "this server won't serve me" — so it would abandon the resolver, breaking
634/// ordinary client lookups (HTTPS/SVCB type 65 issued routinely by browsers for HTTP/3 + ECH, plus
635/// MX/TXT/SRV) for off-tailnet names that A/AAAA queries already forward. Refusing these was never an
636/// anti-leak measure (the same name's A/AAAA already egresses); it was just broken interop.
637///
638/// Anti-leak is preserved: a tailnet-suffix name still never leaves this node (NODATA, not forward),
639/// exactly as the A/AAAA path keeps a positive overlay match authoritative.
640fn forward_or_nodata(
641    view: &DnsView,
642    canon: &str,
643    buf: &[u8],
644    id: u16,
645    q: &ts_dns_wire::Question,
646    rd: bool,
647) -> Decision {
648    // Authoritative tailnet name. For most unsupported types we answer NODATA (empty NOERROR) — the
649    // name exists, we just hold no record of that type. But a small set of types Go's resolver
650    // *explicitly* leaves unimplemented (`net/dns/resolver/tsdns.go` `resolveLocal`:
651    // `case dns.TypeNS, dns.TypeSOA, dns.TypeAXFR, dns.TypeHINFO: return RCodeNotImplemented`) must
652    // answer NOTIMP, not NODATA — a `dig NS`/`SOA`/`HINFO` against the tailnet zone is otherwise a
653    // clean fingerprint distinguishing this fork from real tailscaled. Off-tailnet names are
654    // unaffected (they forward below regardless of type); this NOTIMP applies only to a name we are
655    // authoritative for.
656    if is_tailnet_name(view, canon) {
657        let rcode = if is_unimplemented_tailnet_qtype(&q.qtype) {
658            Rcode::NotImpl
659        } else {
660            Rcode::NoError
661        };
662        // No SOA. Go sets `SOAZone` on a no-data answer only for an A/AAAA/ALL question; a TXT or
663        // SRV miss on a name we serve — and the NOTIMP types — go back bare, as they do upstream.
664        return Decision::Reply(encode_response(id, q, rd, rcode, &[], None));
665    }
666    // Anti-leak parity with the `QType::Ptr` arm: a reverse query for a tailnet CGNAT IPv4
667    // (100.64.0.0/10) or ANY `ip6.arpa` name must NEVER egress to an upstream resolver, regardless
668    // of qtype/class — forwarding it would reveal that a specific tailnet IP was probed. The PTR arm
669    // enforces this (NXDOMAIN) but its guards live only inside that arm; without re-checking here, an
670    // exotic-qtype (TXT/ANY/…) or non-IN-class query for a tailnet reverse name would slip through to
671    // the forward path below. Fail closed to NXDOMAIN, matching the PTR arm's disposition.
672    if is_ip6_arpa(canon) {
673        // No SOA: see the matching guard in [`decide`]'s PTR arm.
674        return Decision::Reply(encode_response(id, q, rd, Rcode::NxDomain, &[], None));
675    }
676    if let Some(octets) = q.name.ptr_to_ipv4()
677        && is_tailnet_cgnat(octets.into())
678    {
679        // Authoritative for the CGNAT reverse zone, so this NXDOMAIN carries its SOA — same
680        // disposition as the PTR arm, whatever the qtype or class that got us here.
681        return Decision::Reply(encode_response(
682            id,
683            q,
684            rd,
685            Rcode::NxDomain,
686            &[],
687            soa_for(view, &q.name, canon).as_ref(),
688        ));
689    }
690    // Off-tailnet, non-reverse-zone: forward verbatim. `forward_or_nxdomain` already forwards
691    // non-tailnet names and soft-fails (SERVFAIL) when no upstream is configured/routable; reuse it
692    // (the tailnet branch above is already handled, so its tailnet→NXDOMAIN and negative-route paths
693    // are unreachable here — this only exercises its off-tailnet forward / SERVFAIL dispositions).
694    forward_or_nxdomain(view, canon, buf, id, q, rd)
695}
696
697/// Client-side plan for a *recursive* forward: keep resolving over local UDP upstreams, or delegate
698/// the query to the active exit node's peerAPI DoH endpoint over the overlay.
699#[derive(Debug, PartialEq, Eq)]
700pub(crate) enum RecursivePlan {
701    /// Forward over UDP to these upstreams. Used when no exit node is active, or when the config
702    /// has `use_with_exit_node` resolvers (kept local even with an exit node selected).
703    Udp(Vec<SocketAddr>),
704    /// Delegate the query to the exit node's peerAPI DoH server at this overlay address.
705    Doh(SocketAddr),
706}
707
708/// Decide whether a recursive forward should stay on local UDP upstreams or be delegated to the
709/// active exit node's DoH endpoint. Pure (no I/O) so the delegation rule is unit-testable.
710///
711/// - No active exit node ([`DnsView::exit_doh`] is `None`) => keep `default_upstreams` (UDP).
712/// - Exit node active, but the config has [`use_with_exit_node`][ts_control::DnsResolver::use_with_exit_node]
713///   resolvers => those resolvers stay local (Go keeps `UseWithExitNode` resolvers when an exit node
714///   is selected); forward to them over UDP, do NOT delegate.
715/// - Exit node active, no kept-local resolvers => delegate to the exit node's DoH. Recursive DNS
716///   then egresses from the exit node, not this host (the whole point of routing through an exit
717///   node: this node's real IP is never used to resolve the peer's public names).
718pub(crate) fn recursive_plan(view: &DnsView, default_upstreams: Vec<SocketAddr>) -> RecursivePlan {
719    let Some(doh) = view.exit_doh else {
720        return RecursivePlan::Udp(default_upstreams);
721    };
722    let kept: Vec<SocketAddr> = view
723        .cfg
724        .resolvers_with_exit_node()
725        .map(DnsResolver::udp_addr)
726        // Anti-leak / IPv6-off: only ever resolve over IPv4 upstreams; never open a v6 socket.
727        .filter(SocketAddr::is_ipv4)
728        .collect();
729    if kept.is_empty() {
730        RecursivePlan::Doh(doh)
731    } else {
732        RecursivePlan::Udp(kept)
733    }
734}
735
736/// Cap a forwarded upstream response to a single UDP datagram ([`MAX_UPSTREAM_RESPONSE`]) before
737/// relaying it, then mark it truncated if it is bigger than what `query`'s sender said it can
738/// receive ([`set_tc_if_over_client_limit`]).
739///
740/// The two checks **compose**; they are not alternatives. The [`MAX_UPSTREAM_RESPONSE`] cap is this
741/// forwarder's own relay bound: when the response is too large it is truncated mid-message, so we
742/// set the `TC` (truncation) flag in the DNS header (byte 2, bit `0x02`) telling the stub resolver
743/// to retry over TCP — relaying a chopped answer without `TC` would surface a
744/// malformed-but-"complete" message. That flag is only set when truncation actually occurs. The
745/// second check is the *client's* bound, and never chops the body.
746///
747/// The cap runs *after* the whole datagram has been read (see [`MAX_UPSTREAM_RESPONSE`]), so it
748/// bounds what we relay, not what we allocate — and while the cap is ≥ the netstack's UDP receive
749/// ring the truncating branch cannot fire on a datagram that ring could deliver. The client-limit
750/// check is therefore what actually sets `TC` on this path in practice.
751fn cap_response(query: &[u8], mut resp: Vec<u8>) -> Vec<u8> {
752    if resp.len() > MAX_UPSTREAM_RESPONSE {
753        resp.truncate(MAX_UPSTREAM_RESPONSE);
754        // The header is 12 bytes; the TC bit lives in the second flags byte (header byte 2). A
755        // capped datagram is always >= the header length, but guard anyway to never panic.
756        if let Some(flags_hi) = resp.get_mut(2) {
757            *flags_hi |= 0x02;
758        }
759    }
760    set_tc_if_over_client_limit(query, resp)
761}
762
763/// The RFC 1035 §4.2.1 maximum size of a DNS message carried over UDP by a requestor that did not
764/// advertise an EDNS(0) buffer size. Also the floor RFC 6891 §6.2.3 puts under an advertised size
765/// ("Values lower than 512 MUST be treated as equal to 512").
766const NO_EDNS_UDP_LIMIT: usize = 512;
767
768/// The RR TYPE of an EDNS(0) OPT pseudo-record (RFC 6891 §6.1.2). In an OPT record the CLASS field
769/// is repurposed to carry the requestor's UDP payload size.
770const OPT_RR_TYPE: u16 = 41;
771
772/// Set the `TC` (truncated) bit on a forwarded `resp` when it is larger than the UDP payload size
773/// the client's `query` advertised — the size in its EDNS(0) OPT record, or 512 bytes when it sent
774/// no OPT record at all (RFC 1035). The body is left **intact**: `TC` tells the stub resolver the
775/// answer may not fit the datagram it asked for, so it should retry over TCP; it is not a claim
776/// that we chopped anything.
777///
778/// This exists because the query is forwarded verbatim and we then relay whatever comes back: a
779/// well-behaved upstream honours the client's EDNS size itself, but "the upstream is well-behaved"
780/// is exactly the assumption to stop making. Without this, a 900-byte reply to a plain non-EDNS
781/// query was relayed with `TC` clear (Go: `checkResponseSizeAndSetTC`, called on every path that
782/// returns a UDP answer).
783///
784/// Applied on the **forwarded** paths only. Answers this node builds itself already fit: `ts_dns_wire`
785/// caps an authoritative response at 512 bytes and sets `TC` when it has to drop an answer, and 512
786/// is the floor under any advertised EDNS size (RFC 6891 §6.2.3), so an authoritative reply can
787/// never exceed a client's limit. The two paths derive their limit differently — one from the
788/// request, one from a fixed constant — but the stricter fixed one can only ever agree.
789pub(crate) fn set_tc_if_over_client_limit(query: &[u8], mut resp: Vec<u8>) -> Vec<u8> {
790    // The header is 12 bytes and the TC bit lives in the second flags byte (header byte 2); a
791    // response shorter than that is not something we can (or need to) mark.
792    if resp.len() > client_udp_limit(query)
793        && let Some(flags_hi) = resp.get_mut(2)
794    {
795        *flags_hi |= 0x02;
796    }
797    resp
798}
799
800/// The largest UDP DNS response `query`'s sender is willing to receive: the EDNS(0) advertised
801/// size, floored at [`NO_EDNS_UDP_LIMIT`] per RFC 6891 §6.2.3, or [`NO_EDNS_UDP_LIMIT`] when the
802/// query carries no OPT record or cannot be walked.
803fn client_udp_limit(query: &[u8]) -> usize {
804    edns_udp_payload_size(query).map_or(NO_EDNS_UDP_LIMIT, |size| size.max(NO_EDNS_UDP_LIMIT))
805}
806
807/// Return the requestor's UDP payload size from `query`'s EDNS(0) OPT record, or [`None`] when there
808/// is no OPT record in the additional section (or the message cannot be walked — a malformed query
809/// falls back to the conservative no-EDNS limit, never to a larger one).
810///
811/// Walks the question, answer and authority sections to reach the additional section, over the same
812/// label sequences [`question_range`] walks — generalised by [`skip_name`] to also step over a
813/// compression pointer, which is legal in a resource record's name and illegal in a question's.
814/// Only the *first* OPT record is consulted; a second one is illegal (RFC 6891 §6.1.1) and we do
815/// not need to reject it here — the query is forwarded verbatim, so the upstream will.
816fn edns_udp_payload_size(query: &[u8]) -> Option<usize> {
817    if query.len() < DNS_HEADER_LEN {
818        return None;
819    }
820    let count = |at: usize| u16::from_be_bytes([query[at], query[at + 1]]) as usize;
821    let (qdcount, ancount, nscount, arcount) = (count(4), count(6), count(8), count(10));
822
823    let mut off = DNS_HEADER_LEN;
824    for _ in 0..qdcount {
825        // QNAME then QTYPE (2) + QCLASS (2).
826        off = skip_name(query, off)?.checked_add(4)?;
827        if off > query.len() {
828            return None;
829        }
830    }
831    for _ in 0..(ancount + nscount) {
832        off = skip_rr_fields(query, skip_name(query, off)?)?;
833    }
834    for _ in 0..arcount {
835        let fields = skip_name(query, off)?;
836        let class_end = fields.checked_add(4)?;
837        if class_end > query.len() {
838            return None;
839        }
840        if u16::from_be_bytes([query[fields], query[fields + 1]]) == OPT_RR_TYPE {
841            // OPT repurposes CLASS as the requestor's UDP payload size (RFC 6891 §6.1.2).
842            return Some(u16::from_be_bytes([query[fields + 2], query[fields + 3]]) as usize);
843        }
844        off = skip_rr_fields(query, fields)?;
845    }
846    None
847}
848
849/// Advance past the DNS name starting at `off`, returning the offset just past it. Handles both an
850/// uncompressed label sequence and a compression pointer (which is two bytes and ends the name,
851/// RFC 1035 §4.1.4 — we never need to follow it, only to step over it). [`None`] on a reserved
852/// label type or a name that runs past the buffer.
853fn skip_name(msg: &[u8], mut off: usize) -> Option<usize> {
854    loop {
855        let len = *msg.get(off)? as usize;
856        match len & 0xC0 {
857            0x00 => {
858                off += 1;
859                if len == 0 {
860                    return Some(off); // root label: the name is complete.
861                }
862                off = off.checked_add(len)?;
863                if off > msg.len() {
864                    return None;
865                }
866            }
867            0xC0 => return off.checked_add(2).filter(|end| *end <= msg.len()),
868            // 0x40 and 0x80 are reserved label types (RFC 6891 §6.1.1 forbids them on the wire).
869            _ => return None,
870        }
871    }
872}
873
874/// Advance past a resource record's fixed fields (TYPE 2, CLASS 2, TTL 4, RDLENGTH 2) and its
875/// RDATA, given `off` — the offset just past that record's NAME. [`None`] if any of it runs past
876/// the buffer.
877fn skip_rr_fields(msg: &[u8], off: usize) -> Option<usize> {
878    let rdlength_at = off.checked_add(8)?;
879    let rdata_at = rdlength_at.checked_add(2)?;
880    if rdata_at > msg.len() {
881        return None;
882    }
883    let rdlength = u16::from_be_bytes([msg[rdlength_at], msg[rdlength_at + 1]]) as usize;
884    rdata_at
885        .checked_add(rdlength)
886        .filter(|end| *end <= msg.len())
887}
888
889/// The byte length of a fixed DNS header.
890const DNS_HEADER_LEN: usize = 12;
891
892/// Return the byte range of the first question section (QNAME + QTYPE + QCLASS) within `msg`,
893/// starting just after the 12-byte header. Returns [`None`] if the name is malformed, uses a
894/// compression pointer (illegal in a question), or runs past the buffer. Used to byte-compare a
895/// forwarded query's question against the upstream response's question.
896fn question_range(msg: &[u8]) -> Option<std::ops::Range<usize>> {
897    let mut off = DNS_HEADER_LEN;
898    // Walk the QNAME label sequence to the terminating root label (0x00).
899    loop {
900        let len = *msg.get(off)? as usize;
901        // A compression pointer (top two bits set) is not valid in a question section.
902        if len & 0xC0 != 0 {
903            return None;
904        }
905        off += 1;
906        if len == 0 {
907            break; // root label: QNAME complete.
908        }
909        off = off.checked_add(len)?;
910        if off > msg.len() {
911            return None;
912        }
913    }
914    // QTYPE (2) + QCLASS (2) follow the name.
915    let end = off.checked_add(4)?;
916    if end > msg.len() {
917        return None;
918    }
919    Some(DNS_HEADER_LEN..end)
920}
921
922/// Whether `resp` is a plausible DNS response to `query`: same 16-bit transaction id, the QR
923/// (response) bit set, and a byte-identical question section (QNAME + QTYPE + QCLASS). Both buffers
924/// carry the DNS header in the first 12 bytes (id at [0..2], flags at [2..4], QR is the high bit of
925/// byte 2). Used to reject off-path/forged datagrams before relaying them back to the stub resolver
926/// as authoritative: matching only the id + QR lets an injector that guesses the id swap in an
927/// answer for a different question, so we also require the echoed question to match.
928fn response_matches_query(query: &[u8], resp: &[u8]) -> bool {
929    if query.len() < DNS_HEADER_LEN || resp.len() < DNS_HEADER_LEN {
930        return false;
931    }
932    let id_matches = query[0..2] == resp[0..2];
933    let is_response = resp[2] & 0x80 != 0;
934    if !id_matches || !is_response {
935        return false;
936    }
937    // The response must echo the exact question we asked. Parse both question sections and compare
938    // their bytes; a parse failure on either side is treated as a non-match (fail closed).
939    match (question_range(query), question_range(resp)) {
940        (Some(q), Some(r)) => query[q] == resp[r],
941        _ => false,
942    }
943}
944
945/// Forward `query` to each upstream in order over the **overlay** netstack, returning the first
946/// well-formed response, or the prebuilt `fallback` buffer if every upstream times out or errors.
947///
948/// The caller supplies `fallback` (a SERVFAIL response for a forwarded off-tailnet name — an
949/// all-upstream failure is a soft "couldn't resolve", not a cacheable non-existence, matching Go
950/// forwarder.go:1297-1307). Keeping it caller-supplied means this fn is rcode-agnostic.
951///
952/// Anti-leak: forwarding goes through the overlay netstack `channel` (a fresh `0.0.0.0:0` overlay
953/// UDP socket per query), NEVER a host socket — so the real origin IP can't leak to the resolver,
954/// and split-DNS upstreams reachable only over the tailnet/subnet-router work. Each upstream is
955/// bounded by [`UPSTREAM_TIMEOUT`]; responses go through [`cap_response`], which caps them at
956/// [`MAX_UPSTREAM_RESPONSE`] and marks them truncated when they exceed what `query` advertised it
957/// can receive.
958pub(crate) async fn forward_query(
959    channel: &Channel,
960    upstreams: &[SocketAddr],
961    query: &[u8],
962    fallback: Vec<u8>,
963) -> Vec<u8> {
964    for upstream in upstreams {
965        let socket = match channel
966            .udp_bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0)))
967            .await
968        {
969            Ok(s) => s,
970            Err(e) => {
971                tracing::warn!(error = %e, %upstream, "magic dns upstream bind failed");
972                continue;
973            }
974        };
975
976        if let Err(e) = socket.send_to(*upstream, query).await {
977            tracing::warn!(error = %e, %upstream, "magic dns upstream send failed");
978            continue;
979        }
980
981        match timeout(UPSTREAM_TIMEOUT, socket.recv_from_bytes()).await {
982            Ok(Ok((from, resp))) if !resp.is_empty() => {
983                // Anti-poisoning: only accept a datagram that came from the upstream we queried
984                // and whose DNS header matches this query (same transaction id, QR=response bit
985                // set). An off-path injector racing the real answer is otherwise relayed straight
986                // back to the stub resolver as authoritative.
987                if from.ip() != upstream.ip() || !response_matches_query(query, &resp) {
988                    tracing::debug!(%upstream, %from, "magic dns dropping unsolicited/mismatched response");
989                    continue;
990                }
991                return cap_response(query, resp.to_vec());
992            }
993            Ok(Ok(_)) => continue,
994            Ok(Err(e)) => {
995                tracing::warn!(error = %e, %upstream, "magic dns upstream recv failed");
996                continue;
997            }
998            Err(_) => {
999                tracing::debug!(%upstream, "magic dns upstream timed out");
1000                continue;
1001            }
1002        }
1003    }
1004    fallback
1005}
1006
1007/// Run the receive/answer loop for the bound socket until it (or the netstack) goes away.
1008///
1009/// Authoritative answers are sent inline. Forwarded queries are handled on spawned tasks (each
1010/// cloning the overlay `channel`) so a slow upstream never blocks other queries.
1011async fn serve(
1012    socket: netstack::netsock::UdpSocket,
1013    rx: watch::Receiver<Arc<DnsView>>,
1014    channel: Channel,
1015) {
1016    let socket = Arc::new(socket);
1017    let mut forwards = JoinSet::new();
1018    // Bounds concurrent in-flight forwards (see `MAX_INFLIGHT_FORWARDS`); a permit is held for the
1019    // lifetime of each spawned forward task and released on completion.
1020    let inflight = Arc::new(Semaphore::new(MAX_INFLIGHT_FORWARDS));
1021    loop {
1022        let (src, buf) = match socket.recv_from_bytes().await {
1023            Ok(pkt) => pkt,
1024            Err(e) => {
1025                tracing::warn!(error = %e, "magic dns socket recv failed, stopping responder");
1026                return;
1027            }
1028        };
1029
1030        // Read the freshest view per packet.
1031        let view = rx.borrow().clone();
1032
1033        match decide(&view, &buf) {
1034            // Malformed query: drop silently.
1035            None => continue,
1036            Some(Decision::Reply(resp)) => {
1037                if let Err(e) = socket.send_to(src, &resp).await {
1038                    tracing::warn!(error = %e, %src, "magic dns response send failed");
1039                }
1040            }
1041            Some(Decision::Forward {
1042                upstreams,
1043                query,
1044                servfail,
1045                recursive,
1046            }) => {
1047                // A recursive forward is eligible for exit-node DoH delegation; a split-DNS route
1048                // always stays on its configured upstreams. Decide the plan against the current
1049                // view so a query routed while an exit node is active egresses from that exit node.
1050                let plan = if recursive {
1051                    recursive_plan(&view, upstreams)
1052                } else {
1053                    RecursivePlan::Udp(upstreams)
1054                };
1055                // Fail closed at the in-flight cap: drop the query (the stub resolver retries or
1056                // times out) rather than spawn an unbounded task that pins an overlay socket for up
1057                // to UPSTREAM_TIMEOUT. The permit is moved into the task as a named `_permit` binding
1058                // (NOT `let _ =`, which would drop it immediately) so it is released only when the
1059                // task body completes.
1060                let Ok(permit) = inflight.clone().try_acquire_owned() else {
1061                    tracing::warn!(
1062                        %src,
1063                        max = MAX_INFLIGHT_FORWARDS,
1064                        "magic dns drop: at max in-flight forwarded queries"
1065                    );
1066                    continue;
1067                };
1068                let socket = socket.clone();
1069                let channel = channel.clone();
1070                forwards.spawn(async move {
1071                    let _permit = permit;
1072                    let resp = match plan {
1073                        RecursivePlan::Udp(upstreams) => {
1074                            forward_query(&channel, &upstreams, &query, servfail).await
1075                        }
1076                        RecursivePlan::Doh(doh_addr) => {
1077                            crate::peerapi_doh::forward_doh(&channel, doh_addr, &query, servfail)
1078                                .await
1079                        }
1080                    };
1081                    if let Err(e) = socket.send_to(src, &resp).await {
1082                        tracing::warn!(error = %e, %src, "magic dns forwarded response send failed");
1083                    }
1084                });
1085            }
1086        }
1087
1088        // Reap finished forward tasks without blocking. The unreaped completed-handle backlog is
1089        // bounded by MAX_INFLIGHT_FORWARDS (a task spawns only after acquiring a permit, and there
1090        // are at most that many), so this bounds JoinSet memory too — not just the reap cadence.
1091        while forwards.try_join_next().is_some() {}
1092    }
1093}
1094
1095/// The MagicDNS responder actor.
1096///
1097/// Subscribes to control state (for the DNS config + self node) and peer state (for the peer
1098/// database), keeping a [`DnsView`] that the spawned answer loop reads for every query.
1099pub struct MagicDnsActor {
1100    /// Keeps the socket-serving task alive for the lifetime of the actor.
1101    _joinset: JoinSet<()>,
1102    /// The latest view, shared with the answer loop.
1103    view_tx: watch::Sender<Arc<DnsView>>,
1104    /// The runtime [`Env`], retained so each view rebuild (the `StateUpdate` / `PeerState` handlers)
1105    /// can re-read the live [`Env::accept_dns`] cell. Unlike `enable_ipv6` (snapshotted once at
1106    /// spawn), `accept_dns` is runtime-settable via `Device::set_accept_dns`, so it must be read at
1107    /// rebuild time — not captured once — for a toggle to reach the served view.
1108    env: Env,
1109    /// The overlay channel, retained so the [`Query`] handler can run a query through the same
1110    /// forward path the serve loop uses ([`forward_query`] / [`forward_doh`], both binding
1111    /// `0.0.0.0:0` on this channel — never a host socket).
1112    channel: Channel,
1113}
1114
1115/// A programmatic DNS query routed through the live MagicDNS responder (the `100.100.100.100` path),
1116/// for [`Device::query_dns`](crate::Device::query_dns). The handler synthesizes a query packet and
1117/// drives it through the exact same [`decide`]/forward logic as an on-the-wire query, so the result
1118/// (and its anti-leak posture) matches what a tailnet client would observe.
1119pub struct Query {
1120    /// The canonical name to resolve (e.g. `example.com`, no trailing dot).
1121    pub name: String,
1122    /// The DNS query type (`1`=A, `28`=AAAA, `12`=PTR, or any other RFC 1035 TYPE).
1123    pub qtype: u16,
1124}
1125
1126/// The outcome of a `Query`: the raw DNS response bytes, the RCODE, and which upstream resolvers
1127/// (if any) were consulted. The response is returned as raw bytes (matching Go `LocalClient.QueryDNS`)
1128/// rather than parsed records — this fork's wire codec has no answer-record decoder.
1129///
1130/// (`Query` is the crate-internal actor message; not linked here as it is a private item — a
1131/// `pub` doc cannot intra-doc-link to it without erroring under the doc-lint gate.)
1132#[derive(Debug, Clone, kameo::Reply)]
1133pub struct DnsQueryResult {
1134    /// The raw DNS response datagram (header + question + any answer records).
1135    pub response: Vec<u8>,
1136    /// The RCODE from the response header's low 4 bits (`0`=NoError, `2`=SERVFAIL, `3`=NXDOMAIN,
1137    /// `5`=Refused, …).
1138    pub rcode: u8,
1139    /// The upstream resolver(s) the query was forwarded to. For a UDP forward this is the candidate
1140    /// list tried in order (the forwarder returns on the first that answers); for an exit-node DoH
1141    /// forward it is the single DoH endpoint. Empty for a locally-answered query (an authoritative
1142    /// tailnet name, a NODATA, or a fail-closed NXDOMAIN — nothing egressed).
1143    pub resolvers_consulted: Vec<SocketAddr>,
1144}
1145
1146impl kameo::Actor for MagicDnsActor {
1147    type Args = (Env, Channel);
1148    type Error = Error;
1149
1150    async fn on_start(
1151        (env, channel): Self::Args,
1152        slf: ActorRef<Self>,
1153    ) -> Result<Self, Self::Error> {
1154        env.subscribe::<Arc<ts_control::StateUpdate>>(&slf).await?;
1155        env.subscribe::<Arc<PeerState>>(&slf).await?;
1156        env.subscribe::<crate::route_updater::ActiveExitNode>(&slf)
1157            .await?;
1158
1159        // Seed the view with the runtime's IPv6 gate (default off) and the current accept-dns value.
1160        // Subsequent control/peer updates clone-and-modify this view: `enable_ipv6` (set once here)
1161        // is preserved, while `accept_dns` is re-read live from `Env` on every rebuild (it is
1162        // runtime-settable). The seed value is moot — no query is served before the first
1163        // StateUpdate — but seeding it keeps the pre-update view internally consistent.
1164        let (view_tx, view_rx) = watch::channel(Arc::new(DnsView {
1165            enable_ipv6: env.enable_ipv6,
1166            accept_dns: env.accept_dns(),
1167            ..DnsView::default()
1168        }));
1169
1170        let mut joinset = JoinSet::new();
1171
1172        // Bind the MagicDNS socket. If the bind fails we still start (fail closed: the actor just
1173        // never answers anything) so a transient bind error doesn't take down the runtime.
1174        let addr = SocketAddr::from((MAGIC_DNS_IP, MAGIC_DNS_PORT));
1175        match channel.udp_bind(addr).await {
1176            Ok(socket) => {
1177                tracing::debug!(%addr, "magic dns responder bound");
1178                joinset.spawn(serve(socket, view_rx.clone(), channel.clone()));
1179            }
1180            Err(e) => {
1181                tracing::error!(error = %e, %addr, "magic dns udp bind failed; responder inert");
1182            }
1183        }
1184
1185        // When this node advertises a peerAPI port, run the single peerAPI server on the same shared
1186        // view. It routes `/dns-query` to the exit-node DoH handler (recursive resolution gated by
1187        // `forward_exit_egress`, see `peerapi_doh`) and `/v0/put/<name>` to the Taildrop receive
1188        // handler when a store is configured (access-gated, fail-closed, see `peerapi`).
1189        if let Some(port) = env.peerapi_port {
1190            let channel = channel.clone();
1191            let view_rx = view_rx.clone();
1192            let forward_exit_egress = env.forward_exit_egress;
1193            let taildrop = env.taildrop_store.clone();
1194            let funnel_ingress = env.funnel_ingress.clone();
1195            joinset.spawn(crate::peerapi::serve(
1196                channel,
1197                port,
1198                view_rx,
1199                forward_exit_egress,
1200                taildrop,
1201                funnel_ingress,
1202            ));
1203        }
1204
1205        Ok(Self {
1206            _joinset: joinset,
1207            view_tx,
1208            env,
1209            channel,
1210        })
1211    }
1212}
1213
1214/// A bare SERVFAIL response header for a [`Query`] whose name could not be encoded into a
1215/// well-formed query (a non-ASCII label or an over-255-byte name). A 12-byte header with QR=1 (this
1216/// is a response) and RCODE=2 (server failure); no question or answer section (we never produced a
1217/// parseable question). Lets `query_dns` return a definite, honest RCODE instead of an empty buffer
1218/// that would read back as a fabricated NoError.
1219fn servfail_response() -> Vec<u8> {
1220    let mut resp = vec![0u8; 12];
1221    // Flags: QR=1 (byte 2, 0x80) + RCODE=2 (low nibble of byte 3). All other bits clear.
1222    resp[2] = 0x80;
1223    resp[3] = 0x02;
1224    resp
1225}
1226
1227impl Message<Query> for MagicDnsActor {
1228    type Reply = DnsQueryResult;
1229
1230    async fn handle(&mut self, query: Query, _ctx: &mut Context<Self, Self::Reply>) -> Self::Reply {
1231        // Synthesize a query packet and drive it through the SAME decide/forward path the serve loop
1232        // uses, against the freshest view — so the result and its anti-leak posture exactly match an
1233        // on-the-wire query. The id is fixed (0): a programmatic query has no concurrent-demux need,
1234        // and `response_matches_query` validates the echoed id against this same buffer.
1235        //
1236        // Normalize the name into labels: strip a single trailing dot (an FQDN's root marker — Go's
1237        // `dnsname.ToFQDN` does the same) and drop empty labels. An empty label would otherwise encode
1238        // as a lone `0x00`, identical to the QNAME root terminator, truncating the wire query and
1239        // corrupting the QTYPE/QCLASS that follow.
1240        let trimmed = query.name.strip_suffix('.').unwrap_or(&query.name);
1241        let labels: Vec<String> = trimmed
1242            .split('.')
1243            .filter(|label| !label.is_empty())
1244            .map(str::to_owned)
1245            .collect();
1246        let qtype = match query.qtype {
1247            1 => ts_dns_wire::QType::A,
1248            28 => ts_dns_wire::QType::Aaaa,
1249            12 => ts_dns_wire::QType::Ptr,
1250            other => ts_dns_wire::QType::Other(other),
1251        };
1252        // Class IN (1) — the only class the responder serves authoritatively (a non-IN class still
1253        // forwards via `forward_or_nodata`, matching the on-the-wire path).
1254        let buf = ts_dns_wire::encode_query(0, &ts_dns_wire::Name(labels), &qtype, 1);
1255
1256        let view = self.view_tx.borrow().clone();
1257
1258        let (response, resolvers_consulted) = match decide(&view, &buf) {
1259            // `decide` returns `None` only when `decode_query` rejects the buffer we just built. With
1260            // the name normalized above that can still happen for a name `encode_query` accepts but
1261            // `decode_query` rejects — a non-ASCII/IDN label (the caller must pass punycode) or a name
1262            // whose wire form exceeds 255 bytes. Surface a SERVFAIL (RCODE 2: "could not process")
1263            // rather than an empty buffer that would read back as a fabricated NoError. The serve loop
1264            // silently drops here (the on-wire client times out); a programmatic caller gets a
1265            // definite, honest error instead.
1266            None => (servfail_response(), Vec::new()),
1267            Some(Decision::Reply(resp)) => (resp, Vec::new()),
1268            Some(Decision::Forward {
1269                upstreams,
1270                query,
1271                servfail,
1272                recursive,
1273            }) => {
1274                let plan = if recursive {
1275                    recursive_plan(&view, upstreams)
1276                } else {
1277                    RecursivePlan::Udp(upstreams)
1278                };
1279                match plan {
1280                    RecursivePlan::Udp(upstreams) => {
1281                        let resp = forward_query(&self.channel, &upstreams, &query, servfail).await;
1282                        (resp, upstreams)
1283                    }
1284                    RecursivePlan::Doh(doh_addr) => {
1285                        let resp = crate::peerapi_doh::forward_doh(
1286                            &self.channel,
1287                            doh_addr,
1288                            &query,
1289                            servfail,
1290                        )
1291                        .await;
1292                        // The query egressed via the exit node's DoH endpoint, not a local UDP
1293                        // upstream — report the DoH address as the resolver consulted.
1294                        (resp, vec![doh_addr])
1295                    }
1296                }
1297            }
1298        };
1299
1300        // RCODE is the low 4 bits of the second flags byte (header byte 3).
1301        let rcode = response.get(3).map(|b| b & 0x0F).unwrap_or(0);
1302
1303        DnsQueryResult {
1304            response,
1305            rcode,
1306            resolvers_consulted,
1307        }
1308    }
1309}
1310
1311impl Message<Arc<ts_control::StateUpdate>> for MagicDnsActor {
1312    type Reply = ();
1313
1314    async fn handle(
1315        &mut self,
1316        update: Arc<ts_control::StateUpdate>,
1317        _ctx: &mut Context<Self, Self::Reply>,
1318    ) {
1319        // Re-read the live accept-dns cell on every rebuild (it is runtime-settable via
1320        // `Device::set_accept_dns`); `enable_ipv6` is preserved from the seed (set once at spawn).
1321        let accept_dns = self.env.accept_dns();
1322        self.view_tx.send_modify(|view| {
1323            let mut next = (**view).clone();
1324            next.cfg = update.dns_config.clone().unwrap_or_default();
1325            next.self_node = update.node.clone();
1326            next.accept_dns = accept_dns;
1327            *view = Arc::new(next);
1328        });
1329    }
1330}
1331
1332impl Message<Arc<PeerState>> for MagicDnsActor {
1333    type Reply = ();
1334
1335    async fn handle(&mut self, state: Arc<PeerState>, _ctx: &mut Context<Self, Self::Reply>) {
1336        // Re-read the live accept-dns cell on every rebuild: `Device::set_accept_dns` triggers a
1337        // `RepublishState` that lands here, so this is the path that re-applies the gate after a
1338        // runtime toggle (covers the netstack responder AND the peerAPI DoH server sharing the view).
1339        let accept_dns = self.env.accept_dns();
1340        self.view_tx.send_modify(|view| {
1341            let mut next = (**view).clone();
1342            next.peers = Some(state.peers.clone());
1343            next.accept_dns = accept_dns;
1344            *view = Arc::new(next);
1345        });
1346    }
1347}
1348
1349impl Message<crate::route_updater::ActiveExitNode> for MagicDnsActor {
1350    type Reply = ();
1351
1352    async fn handle(
1353        &mut self,
1354        active: crate::route_updater::ActiveExitNode,
1355        _ctx: &mut Context<Self, Self::Reply>,
1356    ) {
1357        // Cache the active exit node's DoH endpoint so the serve loop delegates recursive queries
1358        // to it. `None` (no exit node, or one that can't proxy DNS) keeps recursion local. Resolving
1359        // the address here — once, from the route updater's authoritative selection — means the
1360        // serve loop never re-resolves the selector.
1361        let exit_doh = active.node.as_ref().and_then(|n| n.peerapi_doh_addr());
1362        self.view_tx.send_modify(|view| {
1363            let mut next = (**view).clone();
1364            next.exit_doh = exit_doh;
1365            *view = Arc::new(next);
1366        });
1367    }
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372    use ts_control::{StableNodeId, TailnetAddress};
1373
1374    use super::*;
1375
1376    /// Test wrapper: run [`decide`] and extract the reply bytes. These tests configure no
1377    /// upstream resolvers, so an unresolved name fails closed to a `Reply` (NXDOMAIN), never a
1378    /// `Forward`; a `Forward` here is a bug and panics.
1379    fn answer(view: &DnsView, buf: &[u8]) -> Option<Vec<u8>> {
1380        match decide(view, buf)? {
1381            Decision::Reply(resp) => Some(resp),
1382            Decision::Forward { .. } => panic!("unexpected forward in authoritative-only test"),
1383        }
1384    }
1385
1386    /// Build a `Node` named `host.user.ts.net` with a known v4/v6 tailnet address.
1387    fn test_node() -> Node {
1388        Node {
1389            id: 1,
1390            stable_id: StableNodeId("n1".to_string()),
1391            hostname: "host".to_string(),
1392            user_id: 0,
1393            tailnet: Some("user.ts.net".to_string()),
1394            tags: vec![],
1395            addresses: vec![
1396                "100.64.0.1/32".parse().unwrap(),
1397                "fd7a::1/128".parse().unwrap(),
1398            ],
1399            tailnet_address: TailnetAddress {
1400                ipv4: "100.64.0.1/32".parse().unwrap(),
1401                ipv6: "fd7a::1/128".parse().unwrap(),
1402            },
1403            node_key: [0u8; 32].into(),
1404            node_key_expiry: None,
1405            online: None,
1406            last_seen: None,
1407            key_signature: vec![],
1408            machine_key: None,
1409            disco_key: None,
1410            accepted_routes: vec![],
1411            underlay_addresses: vec![],
1412            derp_region: None,
1413            cap: Default::default(),
1414            cap_map: Default::default(),
1415            peerapi_port: None,
1416            peerapi_dns_proxy: false,
1417            is_wireguard_only: false,
1418            exit_node_dns_resolvers: vec![],
1419            peer_relay: false,
1420            ssh_host_keys: vec![],
1421            service_vips: Default::default(),
1422            unsigned_peer_api_only: false,
1423        }
1424    }
1425
1426    /// A view with MagicDNS on and a single peer in the db.
1427    fn view_with_peer() -> DnsView {
1428        let mut db = PeerDb::default();
1429        db.upsert(&test_node());
1430
1431        DnsView {
1432            cfg: DnsConfig {
1433                magic_dns: true,
1434                search_domains: vec!["user.ts.net".to_string()],
1435                ..Default::default()
1436            },
1437            peers: Some(Arc::new(db)),
1438            self_node: None,
1439            exit_doh: None,
1440            enable_ipv6: false,
1441            accept_dns: true,
1442        }
1443    }
1444
1445    /// Build a raw DNS query buffer for `labels` with the given id, qtype, qclass.
1446    fn build_query(id: u16, labels: &[&str], qtype: u16, qclass: u16) -> Vec<u8> {
1447        let mut buf: Vec<u8> = Vec::new();
1448        buf.extend_from_slice(&id.to_be_bytes());
1449        buf.extend_from_slice(&0u16.to_be_bytes()); // flags: QR=0 (query)
1450        buf.extend_from_slice(&1u16.to_be_bytes()); // QDCOUNT
1451        buf.extend_from_slice(&0u16.to_be_bytes()); // ANCOUNT
1452        buf.extend_from_slice(&0u16.to_be_bytes()); // NSCOUNT
1453        buf.extend_from_slice(&0u16.to_be_bytes()); // ARCOUNT
1454        for label in labels {
1455            buf.push(label.len() as u8);
1456            buf.extend_from_slice(label.as_bytes());
1457        }
1458        buf.push(0); // root label
1459        buf.extend_from_slice(&qtype.to_be_bytes());
1460        buf.extend_from_slice(&qclass.to_be_bytes());
1461        buf
1462    }
1463
1464    /// `build_query` plus an EDNS(0) OPT record in the additional section advertising `udp_size`
1465    /// as the requestor's UDP payload size (RFC 6891: root NAME, TYPE 41, CLASS = the size).
1466    fn build_edns_query(
1467        id: u16,
1468        labels: &[&str],
1469        qtype: u16,
1470        qclass: u16,
1471        udp_size: u16,
1472    ) -> Vec<u8> {
1473        let mut buf = build_query(id, labels, qtype, qclass);
1474        buf[11] = 1; // ARCOUNT = 1
1475        buf.push(0); // NAME: root
1476        buf.extend_from_slice(&41u16.to_be_bytes()); // TYPE: OPT
1477        buf.extend_from_slice(&udp_size.to_be_bytes()); // CLASS: requestor's UDP payload size
1478        buf.extend_from_slice(&0u32.to_be_bytes()); // TTL: extended rcode + flags
1479        buf.extend_from_slice(&0u16.to_be_bytes()); // RDLENGTH: no options
1480        buf
1481    }
1482
1483    /// Parse a response header: returns `(id, rcode, ancount)`.
1484    fn parse_header(resp: &[u8]) -> (u16, u8, u16) {
1485        let id = u16::from_be_bytes([resp[0], resp[1]]);
1486        let flags = u16::from_be_bytes([resp[2], resp[3]]);
1487        let ancount = u16::from_be_bytes([resp[6], resp[7]]);
1488        (id, (flags & 0x000F) as u8, ancount)
1489    }
1490
1491    #[test]
1492    fn a_query_for_known_peer_answers_v4() {
1493        let view = view_with_peer();
1494        let buf = build_query(0x1234, &["host", "user", "ts", "net"], 1, 1);
1495
1496        let resp = answer(&view, &buf).expect("answers");
1497        let (id, rcode, ancount) = parse_header(&resp);
1498        assert_eq!(id, 0x1234);
1499        assert_eq!(rcode, 0, "NoError");
1500        assert_eq!(ancount, 1);
1501
1502        // The trailing RDATA of the single A record is the peer's tailnet v4 octets.
1503        let tail = &resp[resp.len() - 4..];
1504        assert_eq!(tail, &[100, 64, 0, 1]);
1505    }
1506
1507    #[test]
1508    fn aaaa_query_for_known_peer_is_nodata_when_ipv6_off() {
1509        // Gate OFF (default): an AAAA query for a known overlay peer must return NoError with an
1510        // empty answer (NODATA) — NOT the overlay v6 address, which the IPv4-only client can't
1511        // route. This is the anti-fingerprint / no-dead-connections posture.
1512        let view = view_with_peer();
1513        assert!(!view.enable_ipv6, "default gate is off");
1514        let buf = build_query(0x5, &["host", "user", "ts", "net"], 28, 1);
1515
1516        let resp = answer(&view, &buf).expect("answers");
1517        let (_, rcode, ancount) = parse_header(&resp);
1518        assert_eq!(rcode, 0, "NoError (NODATA)");
1519        assert_eq!(ancount, 0, "empty answer: no AAAA handed out with IPv6 off");
1520    }
1521
1522    #[test]
1523    fn a_query_still_resolves_when_ipv6_off() {
1524        // Gate OFF must not touch the A (v4) path: the v4 answer is byte-for-byte unchanged.
1525        let view = view_with_peer();
1526        let buf = build_query(0x6, &["host", "user", "ts", "net"], 1, 1);
1527
1528        let resp = answer(&view, &buf).expect("answers");
1529        let (_, rcode, ancount) = parse_header(&resp);
1530        assert_eq!(rcode, 0, "NoError");
1531        assert_eq!(ancount, 1);
1532        let tail = &resp[resp.len() - 4..];
1533        assert_eq!(tail, &[100, 64, 0, 1]);
1534    }
1535
1536    #[test]
1537    fn aaaa_query_for_known_peer_answers_v6_when_ipv6_on() {
1538        // Gate ON: historical behavior — answer AAAA from the overlay v6 address.
1539        let mut view = view_with_peer();
1540        view.enable_ipv6 = true;
1541        let buf = build_query(0x5, &["host", "user", "ts", "net"], 28, 1);
1542
1543        let resp = answer(&view, &buf).expect("answers");
1544        let (_, rcode, ancount) = parse_header(&resp);
1545        assert_eq!(rcode, 0, "NoError");
1546        assert_eq!(ancount, 1);
1547
1548        let expected = "fd7a::1".parse::<std::net::Ipv6Addr>().unwrap().octets();
1549        let tail = &resp[resp.len() - 16..];
1550        assert_eq!(tail, expected);
1551    }
1552
1553    #[test]
1554    fn aaaa_for_unknown_tailnet_name_is_nxdomain_not_forwarded_with_ipv6_off() {
1555        // Anti-leak, unchanged by the gate: an AAAA for a name under the tailnet suffix that has no
1556        // overlay match still fails closed to NXDOMAIN — never forwarded to a recursive upstream,
1557        // even with resolvers configured. (Gate OFF only changes the *positive* overlay match into
1558        // NODATA; a non-match still routes through `forward_or_nxdomain`.)
1559        let mut db = PeerDb::default();
1560        db.upsert(&test_node());
1561        let view = DnsView {
1562            cfg: DnsConfig {
1563                magic_dns: true,
1564                search_domains: vec!["user.ts.net".to_string()],
1565                fallback_resolvers: vec![DnsResolver {
1566                    transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
1567                    use_with_exit_node: false,
1568                }],
1569                ..Default::default()
1570            },
1571            peers: Some(Arc::new(db)),
1572            self_node: None,
1573            exit_doh: None,
1574            enable_ipv6: false,
1575            accept_dns: true,
1576        };
1577        let buf = build_query(0x5A, &["ghost", "user", "ts", "net"], 28, 1);
1578
1579        match decide(&view, &buf).expect("decides") {
1580            Decision::Reply(resp) => {
1581                let (_, rcode, _) = parse_header(&resp);
1582                assert_eq!(rcode, 3, "NxDomain: tailnet AAAA not leaked upstream");
1583            }
1584            Decision::Forward { .. } => panic!("tailnet AAAA must never be forwarded"),
1585        }
1586    }
1587
1588    #[test]
1589    fn bare_hostname_resolves() {
1590        // The name index also stores the bare hostname.
1591        let view = view_with_peer();
1592        let buf = build_query(0x7, &["host"], 1, 1);
1593
1594        let resp = answer(&view, &buf).expect("answers");
1595        let (_, rcode, ancount) = parse_header(&resp);
1596        assert_eq!(rcode, 0);
1597        assert_eq!(ancount, 1);
1598    }
1599
1600    #[test]
1601    fn unknown_off_tailnet_name_with_no_upstream_is_servfail() {
1602        // An off-tailnet name with no resolver configured cannot be forwarded. Go answers SERVFAIL
1603        // (a soft "couldn't resolve"), not NXDOMAIN — asserting non-existence of a real name we
1604        // simply have no upstream for would poison a downstream stub's negative cache. (A *tailnet*
1605        // name with no overlay match stays NXDOMAIN — see `tailnet_name_is_never_forwarded` — and a
1606        // negative split-DNS route stays NXDOMAIN — see `negative_route_is_nxdomain_not_forwarded`.)
1607        let view = view_with_peer();
1608        let buf = build_query(0x9, &["nope", "example", "com"], 1, 1);
1609
1610        let resp = answer(&view, &buf).expect("answers");
1611        let (_, rcode, ancount) = parse_header(&resp);
1612        assert_eq!(
1613            rcode, 2,
1614            "ServFail: off-tailnet name, nothing to forward to"
1615        );
1616        assert_eq!(ancount, 0);
1617    }
1618
1619    #[test]
1620    fn magic_dns_off_is_refused() {
1621        // Fail closed: with MagicDNS disabled, even a known name is refused.
1622        let mut view = view_with_peer();
1623        view.cfg.magic_dns = false;
1624        let buf = build_query(0xAB, &["host", "user", "ts", "net"], 1, 1);
1625
1626        let resp = answer(&view, &buf).expect("answers");
1627        let (_, rcode, ancount) = parse_header(&resp);
1628        assert_eq!(rcode, 5, "Refused");
1629        assert_eq!(ancount, 0);
1630    }
1631
1632    #[test]
1633    fn accept_dns_false_refuses_otherwise_answerable_query() {
1634        // The accept-dns gate (Go `CorpDNS`): with `accept_dns == false` the node ignores the
1635        // tailnet DNS config, so even a known peer name that would normally answer authoritatively is
1636        // REFUSED (the responder serves nothing) — mirroring Go applying an empty `dns.Config`.
1637        let mut view = view_with_peer();
1638        assert!(view.cfg.magic_dns, "MagicDNS itself is on");
1639        view.accept_dns = false;
1640        let buf = build_query(0xDD, &["host", "user", "ts", "net"], 1, 1);
1641
1642        let resp = answer(&view, &buf).expect("answers");
1643        let (_, rcode, ancount) = parse_header(&resp);
1644        assert_eq!(rcode, 5, "Refused: accept_dns off ⇒ serve nothing");
1645        assert_eq!(ancount, 0);
1646
1647        // Flip accept_dns back ON (the config was never destroyed, only gated): the same query now
1648        // answers authoritatively — proving the OFF→ON restore is automatic.
1649        view.accept_dns = true;
1650        let resp = answer(&view, &buf).expect("answers");
1651        let (_, rcode, ancount) = parse_header(&resp);
1652        assert_eq!(rcode, 0, "NoError: accept_dns on ⇒ the known peer answers");
1653        assert_eq!(ancount, 1);
1654        let tail = &resp[resp.len() - 4..];
1655        assert_eq!(tail, &[100, 64, 0, 1], "the peer's tailnet v4 is served");
1656    }
1657
1658    #[test]
1659    fn default_view_serves_nothing() {
1660        // The default (no dns_config seen) has magic_dns == false: fail closed.
1661        let view = DnsView::default();
1662        let buf = build_query(0x1, &["host", "user", "ts", "net"], 1, 1);
1663
1664        let resp = answer(&view, &buf).expect("answers");
1665        let (_, rcode, _) = parse_header(&resp);
1666        assert_eq!(rcode, 5, "Refused");
1667    }
1668
1669    #[test]
1670    fn unsupported_qtype_on_tailnet_name_is_nodata_not_refused() {
1671        // TXT (type 16) for a tailnet-authoritative name: the name exists but we hold no TXT, so —
1672        // like Go — return NODATA (empty NOERROR), NOT REFUSED (which would make a stub abandon the
1673        // resolver) and NOT NXDOMAIN (the name exists). The name is never forwarded (anti-leak).
1674        let view = view_with_peer();
1675        let buf = build_query(0x1, &["host", "user", "ts", "net"], 16, 1);
1676
1677        let resp = answer(&view, &buf).expect("answers");
1678        let (_, rcode, ancount) = parse_header(&resp);
1679        assert_eq!(rcode, 0, "NoError (NODATA), not Refused");
1680        assert_eq!(ancount, 0, "no answer records (NODATA)");
1681    }
1682
1683    #[test]
1684    fn unsupported_qtype_off_tailnet_forwards_or_servfails() {
1685        // A non-A/AAAA/PTR qtype for an OFF-tailnet name must be forwardable like A/AAAA — never
1686        // REFUSED. With no upstream configured in this view it soft-fails to SERVFAIL (the same
1687        // disposition an off-tailnet A query gets here), proving the qtype no longer short-circuits
1688        // to REFUSED. HTTPS/SVCB is type 65 (the browser HTTP/3 + ECH case the old REFUSED broke).
1689        let view = view_with_peer();
1690        let buf = build_query(0x1, &["example", "com"], 65, 1);
1691
1692        let resp = answer(&view, &buf).expect("answers");
1693        let (_, rcode, _) = parse_header(&resp);
1694        assert_eq!(
1695            rcode, 2,
1696            "off-tailnet, no upstream -> SERVFAIL (forwardable, not Refused)"
1697        );
1698    }
1699
1700    #[test]
1701    fn unimplemented_qtype_on_tailnet_name_is_notimp() {
1702        // NS (2), SOA (6), HINFO (13), AXFR (252) for a tailnet-authoritative name must answer NOTIMP
1703        // (rcode 4), matching Go `resolveLocal`'s `case dns.TypeNS, dns.TypeSOA, dns.TypeAXFR,
1704        // dns.TypeHINFO: return RCodeNotImplemented`. Returning NODATA (rcode 0) here was a clean
1705        // fingerprint (a `dig SOA user.ts.net` answer differs from real tailscaled). The name is
1706        // still never forwarded (anti-leak).
1707        let view = view_with_peer();
1708        for qtype in [2u16, 6, 13, 252] {
1709            let buf = build_query(0x1, &["host", "user", "ts", "net"], qtype, 1);
1710            let resp = answer(&view, &buf).expect("answers");
1711            let (_, rcode, ancount) = parse_header(&resp);
1712            assert_eq!(rcode, 4, "qtype {qtype} on a tailnet name must be NOTIMP");
1713            assert_eq!(ancount, 0, "NOTIMP carries no answer records");
1714        }
1715    }
1716
1717    #[test]
1718    fn unimplemented_qtype_off_tailnet_still_forwards_not_notimp() {
1719        // The NOTIMP disposition is ONLY for a name we are authoritative for. An NS query for an
1720        // off-tailnet name must still forward (here: SERVFAIL, no upstream) — NOT NOTIMP — exactly
1721        // like the off-tailnet HTTPS/SVCB case above. Guards the NOTIMP change against over-reach.
1722        let view = view_with_peer();
1723        let buf = build_query(0x1, &["example", "com"], 2, 1); // NS, off-tailnet
1724        let resp = answer(&view, &buf).expect("answers");
1725        let (_, rcode, _) = parse_header(&resp);
1726        assert_eq!(
1727            rcode, 2,
1728            "off-tailnet NS -> SERVFAIL (forwardable), not NOTIMP"
1729        );
1730    }
1731
1732    #[test]
1733    fn malformed_query_is_dropped() {
1734        // A response (QR bit set) is not a query; we drop it (no answer).
1735        let mut buf = build_query(0x1, &["host"], 1, 1);
1736        buf[2] = 0x80; // set QR bit
1737        assert!(answer(&view_with_peer(), &buf).is_none());
1738    }
1739
1740    #[test]
1741    fn ptr_for_known_ip_answers_fqdn() {
1742        let view = view_with_peer();
1743        // Reverse name for 100.64.0.1 => 1.0.64.100.in-addr.arpa
1744        let buf = build_query(0x33, &["1", "0", "64", "100", "in-addr", "arpa"], 12, 1);
1745
1746        let resp = answer(&view, &buf).expect("answers");
1747        let (_, rcode, ancount) = parse_header(&resp);
1748        assert_eq!(rcode, 0, "NoError");
1749        assert_eq!(ancount, 1);
1750
1751        // The PTR rdata encodes the peer's fqdn "host.user.ts.net" as length-prefixed labels.
1752        let expected = {
1753            let mut out = Vec::new();
1754            for label in ["host", "user", "ts", "net"] {
1755                out.push(label.len() as u8);
1756                out.extend_from_slice(label.as_bytes());
1757            }
1758            out.push(0);
1759            out
1760        };
1761        let tail = &resp[resp.len() - expected.len()..];
1762        assert_eq!(tail, expected.as_slice());
1763    }
1764
1765    #[test]
1766    fn ptr_for_unknown_public_ip_off_tailnet_is_servfail() {
1767        let view = view_with_peer();
1768        // 9.9.9.9 is a public IP, not a known tailnet IP and not in the CGNAT reverse zone — so its
1769        // reverse query is an ordinary off-tailnet name. With no upstream to forward it to, that is
1770        // SERVFAIL (soft), not NXDOMAIN. (A CGNAT/ip6.arpa reverse for an unmatched tailnet IP still
1771        // fails closed to NXDOMAIN as an anti-leak guard — see `ptr_for_unknown_tailnet_ip_*`.)
1772        let buf = build_query(0x34, &["9", "9", "9", "9", "in-addr", "arpa"], 12, 1);
1773
1774        let resp = answer(&view, &buf).expect("answers");
1775        let (_, rcode, _) = parse_header(&resp);
1776        assert_eq!(
1777            rcode, 2,
1778            "ServFail: off-tailnet public-IP reverse, no upstream"
1779        );
1780    }
1781
1782    #[test]
1783    fn ptr_for_unknown_tailnet_ip_is_nxdomain_not_forwarded() {
1784        // A view WITH an upstream resolver: an off-tailnet reverse query would forward, but a
1785        // reverse query for an unmatched IP in the CGNAT range (100.64.0.0/10) must fail closed to
1786        // NXDOMAIN — the probed tailnet IP must never leak upstream.
1787        let mut db = PeerDb::default();
1788        db.upsert(&test_node());
1789        let view = DnsView {
1790            cfg: DnsConfig {
1791                magic_dns: true,
1792                search_domains: vec!["user.ts.net".to_string()],
1793                fallback_resolvers: vec![DnsResolver {
1794                    transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
1795                    use_with_exit_node: false,
1796                }],
1797                ..Default::default()
1798            },
1799            peers: Some(Arc::new(db)),
1800            self_node: None,
1801            exit_doh: None,
1802            enable_ipv6: false,
1803            accept_dns: true,
1804        };
1805
1806        // 100.64.0.9 is in CGNAT range but owned by no peer => NXDOMAIN, never a Forward.
1807        let buf = build_query(0x35, &["9", "0", "64", "100", "in-addr", "arpa"], 12, 1);
1808        match decide(&view, &buf).expect("decides") {
1809            Decision::Reply(resp) => {
1810                let (_, rcode, _) = parse_header(&resp);
1811                assert_eq!(rcode, 3, "NxDomain");
1812            }
1813            Decision::Forward { .. } => {
1814                panic!("tailnet CGNAT PTR must never be forwarded upstream")
1815            }
1816        }
1817    }
1818
1819    /// Anti-leak regression for the exotic-qtype forward path: a NON-PTR query (TXT, type 16) for a
1820    /// tailnet CGNAT reverse name, with an upstream configured, must STILL fail closed to NXDOMAIN —
1821    /// never forward. The PTR arm guards this, but the `QType::Other` path routes through
1822    /// `forward_or_nodata`, which must re-apply the reverse-zone guard or the tailnet IP leaks.
1823    #[test]
1824    fn exotic_qtype_for_tailnet_cgnat_reverse_is_nxdomain_not_forwarded() {
1825        let mut db = PeerDb::default();
1826        db.upsert(&test_node());
1827        let view = DnsView {
1828            cfg: DnsConfig {
1829                magic_dns: true,
1830                search_domains: vec!["user.ts.net".to_string()],
1831                fallback_resolvers: vec![DnsResolver {
1832                    transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
1833                    use_with_exit_node: false,
1834                }],
1835                ..Default::default()
1836            },
1837            peers: Some(Arc::new(db)),
1838            self_node: None,
1839            exit_doh: None,
1840            enable_ipv6: false,
1841            accept_dns: true,
1842        };
1843
1844        // TXT (16) for a CGNAT reverse name => NXDOMAIN, never a Forward (no tailnet-IP leak).
1845        let buf = build_query(0x36, &["9", "0", "64", "100", "in-addr", "arpa"], 16, 1);
1846        match decide(&view, &buf).expect("decides") {
1847            Decision::Reply(resp) => {
1848                let (_, rcode, _) = parse_header(&resp);
1849                assert_eq!(rcode, 3, "NxDomain");
1850            }
1851            Decision::Forward { .. } => {
1852                panic!("a non-PTR query for a tailnet CGNAT reverse name must never forward")
1853            }
1854        }
1855    }
1856
1857    /// Same anti-leak guard for an `ip6.arpa` reverse name under an exotic qtype: must NXDOMAIN, not
1858    /// forward (revealing a tailnet ULA was probed).
1859    #[test]
1860    fn exotic_qtype_for_ip6_arpa_is_nxdomain_not_forwarded() {
1861        let view = view_with_routes(
1862            std::collections::BTreeMap::new(),
1863            vec![udp("9.9.9.9:53")],
1864            vec![],
1865        );
1866        // An ip6.arpa reverse name with a TXT (16) qtype must fail closed.
1867        let buf = build_query(
1868            0x37,
1869            &[
1870                "1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0",
1871                "a", "7", "d", "f", "ip6", "arpa",
1872            ],
1873            16,
1874            1,
1875        );
1876        match decide(&view, &buf).expect("decides") {
1877            Decision::Reply(resp) => {
1878                let (_, rcode, _) = parse_header(&resp);
1879                assert_eq!(rcode, 3, "NxDomain");
1880            }
1881            Decision::Forward { .. } => panic!("an ip6.arpa exotic-qtype query must never forward"),
1882        }
1883    }
1884
1885    #[test]
1886    fn is_tailnet_cgnat_classifies_range() {
1887        assert!(is_tailnet_cgnat("100.64.0.0".parse().unwrap()));
1888        assert!(is_tailnet_cgnat("100.64.0.1".parse().unwrap()));
1889        assert!(is_tailnet_cgnat("100.127.255.255".parse().unwrap()));
1890        // Outside the /10:
1891        assert!(!is_tailnet_cgnat("100.63.255.255".parse().unwrap()));
1892        assert!(!is_tailnet_cgnat("100.128.0.0".parse().unwrap()));
1893        assert!(!is_tailnet_cgnat("9.9.9.9".parse().unwrap()));
1894        // The MagicDNS resolver IP 100.100.100.100 is itself inside the /10.
1895        assert!(is_tailnet_cgnat("100.100.100.100".parse().unwrap()));
1896    }
1897
1898    #[test]
1899    fn response_matches_query_validates_id_and_qr() {
1900        // query id 0x1234, QR=0
1901        let query = build_query(0x1234, &["a", "com"], 1, 1);
1902
1903        // A well-formed response: same id, QR=1.
1904        let mut good = query.clone();
1905        good[2] |= 0x80;
1906        assert!(response_matches_query(&query, &good));
1907
1908        // Same id but QR still 0 (not a response): rejected.
1909        assert!(!response_matches_query(&query, &query));
1910
1911        // QR=1 but a different transaction id: rejected (off-path forgery).
1912        let mut wrong_id = good.clone();
1913        wrong_id[0] ^= 0xFF;
1914        assert!(!response_matches_query(&query, &wrong_id));
1915
1916        // Too-short buffers: rejected.
1917        assert!(!response_matches_query(&query, &[0u8; 2]));
1918        assert!(!response_matches_query(&[0u8; 3], &good));
1919    }
1920
1921    #[test]
1922    fn self_node_resolves_when_no_peer_match() {
1923        // With the peer db empty but a self node set, the self node answers for its own name.
1924        let view = DnsView {
1925            cfg: DnsConfig {
1926                magic_dns: true,
1927                search_domains: vec![],
1928                ..Default::default()
1929            },
1930            peers: None,
1931            self_node: Some(test_node()),
1932            exit_doh: None,
1933            enable_ipv6: false,
1934            accept_dns: true,
1935        };
1936        let buf = build_query(0x44, &["host", "user", "ts", "net"], 1, 1);
1937
1938        let resp = answer(&view, &buf).expect("answers");
1939        let (_, rcode, ancount) = parse_header(&resp);
1940        assert_eq!(rcode, 0);
1941        assert_eq!(ancount, 1);
1942        let tail = &resp[resp.len() - 4..];
1943        assert_eq!(tail, &[100, 64, 0, 1]);
1944    }
1945
1946    #[test]
1947    fn partially_qualified_name_resolves_via_search_domain() {
1948        // "host.user" is not indexed directly, but the "user.ts.net" search domain qualifies it
1949        // to "host.user.user.ts.net"... which does NOT match. The realistic case is "host" (bare,
1950        // already indexed) and "host.user.ts.net" (fqdn). Verify a name needing suffix expansion:
1951        // with search domain "ts.net" the partially-qualified "host.user" => "host.user.ts.net".
1952        let mut view = view_with_peer();
1953        view.cfg.search_domains = vec!["ts.net".to_string()];
1954        let buf = build_query(0x55, &["host", "user"], 1, 1);
1955
1956        let resp = answer(&view, &buf).expect("answers");
1957        let (_, rcode, ancount) = parse_header(&resp);
1958        assert_eq!(rcode, 0, "NoError via search-domain expansion");
1959        assert_eq!(ancount, 1);
1960        let tail = &resp[resp.len() - 4..];
1961        assert_eq!(tail, &[100, 64, 0, 1]);
1962    }
1963
1964    #[test]
1965    fn extra_record_a_answers_when_no_peer_match() {
1966        // A control-pushed static A record answers for a non-peer name, fail-closed otherwise.
1967        let mut view = view_with_peer();
1968        view.cfg.extra_records = vec![ts_control::ExtraRecord {
1969            name: "static.user.ts.net".to_string(),
1970            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
1971        }];
1972        let buf = build_query(0x77, &["static", "user", "ts", "net"], 1, 1);
1973
1974        let resp = answer(&view, &buf).expect("answers");
1975        let (_, rcode, ancount) = parse_header(&resp);
1976        assert_eq!(rcode, 0, "NoError from extra record");
1977        assert_eq!(ancount, 1);
1978        let tail = &resp[resp.len() - 4..];
1979        assert_eq!(tail, &[100, 64, 0, 9]);
1980    }
1981
1982    #[test]
1983    fn extra_record_matches_query_case_insensitively() {
1984        // The query name is canonicalized (lowercased) at decode time, so a mixed-case query
1985        // matches a lowercase extra record.
1986        let mut view = view_with_peer();
1987        view.cfg.extra_records = vec![ts_control::ExtraRecord {
1988            name: "static.user.ts.net".to_string(),
1989            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
1990        }];
1991        let buf = build_query(0x7A, &["Static", "User", "TS", "net"], 1, 1);
1992
1993        let resp = answer(&view, &buf).expect("answers");
1994        let (_, rcode, ancount) = parse_header(&resp);
1995        assert_eq!(rcode, 0, "NoError: case-insensitive match");
1996        assert_eq!(ancount, 1);
1997        let tail = &resp[resp.len() - 4..];
1998        assert_eq!(tail, &[100, 64, 0, 9]);
1999    }
2000
2001    #[test]
2002    fn extra_record_not_expanded_by_search_domain() {
2003        // Unlike peer names, an extra record is matched as an FQDN only: a bare query that would
2004        // need search-domain expansion to reach the record name must NOT resolve.
2005        let mut view = view_with_peer();
2006        view.cfg.extra_records = vec![ts_control::ExtraRecord {
2007            name: "static.user.ts.net".to_string(),
2008            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2009        }];
2010        // "static" would only reach "static.user.ts.net" via the "user.ts.net" search domain.
2011        let buf = build_query(0x7B, &["static"], 1, 1);
2012
2013        let resp = answer(&view, &buf).expect("answers");
2014        let (_, rcode, _) = parse_header(&resp);
2015        // Not search-expanded → treated as the bare off-tailnet name "static", which has no upstream
2016        // here, so SERVFAIL (soft). The point of the test — that the extra record is NOT reachable
2017        // via search expansion — holds regardless of the failure rcode.
2018        assert_eq!(
2019            rcode, 2,
2020            "ServFail: bare 'static' is not search-expanded to the extra record"
2021        );
2022    }
2023
2024    #[test]
2025    fn extra_record_aaaa_family_is_isolated() {
2026        // An A-only extra record must NOT answer an AAAA query for the same name (NxDomain).
2027        let mut view = view_with_peer();
2028        view.cfg.extra_records = vec![ts_control::ExtraRecord {
2029            name: "v4only.user.ts.net".to_string(),
2030            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2031        }];
2032        let buf = build_query(0x78, &["v4only", "user", "ts", "net"], 28, 1);
2033
2034        let resp = answer(&view, &buf).expect("answers");
2035        let (_, rcode, _) = parse_header(&resp);
2036        assert_eq!(rcode, 3, "NxDomain: A record does not satisfy AAAA");
2037    }
2038
2039    #[test]
2040    fn extra_record_ignored_when_magic_dns_off() {
2041        // Fail closed: extra records are never served while MagicDNS is disabled.
2042        let mut view = view_with_peer();
2043        view.cfg.magic_dns = false;
2044        view.cfg.extra_records = vec![ts_control::ExtraRecord {
2045            name: "static.user.ts.net".to_string(),
2046            addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2047        }];
2048        let buf = build_query(0x79, &["static", "user", "ts", "net"], 1, 1);
2049
2050        let resp = answer(&view, &buf).expect("answers");
2051        let (_, rcode, _) = parse_header(&resp);
2052        assert_eq!(rcode, 5, "Refused");
2053    }
2054
2055    #[test]
2056    fn non_in_class_on_tailnet_name_is_nodata_not_answered_as_in() {
2057        // A CHAOS-class (3) query for a tailnet name must NOT be answered as IN (no overlay A), and
2058        // must NOT be REFUSED (Go does no class check on the local path). It's an unsupported
2059        // authoritative class -> NODATA (empty NOERROR), and never forwarded (tailnet name).
2060        let view = view_with_peer();
2061        let buf = build_query(0x66, &["host", "user", "ts", "net"], 1, 3);
2062
2063        let resp = answer(&view, &buf).expect("answers");
2064        let (_, rcode, ancount) = parse_header(&resp);
2065        assert_eq!(
2066            rcode, 0,
2067            "NoError (NODATA), not Refused and not an IN answer"
2068        );
2069        assert_eq!(
2070            ancount, 0,
2071            "must not hand out the overlay A for a non-IN class"
2072        );
2073    }
2074
2075    #[test]
2076    fn non_in_class_off_tailnet_forwards_or_servfails() {
2077        // A non-IN class for an OFF-tailnet name is forwardable (Go forwards it), never REFUSED.
2078        // No upstream here -> SERVFAIL, proving the class gate no longer short-circuits to Refused.
2079        let view = view_with_peer();
2080        let buf = build_query(0x66, &["example", "com"], 1, 3);
2081
2082        let resp = answer(&view, &buf).expect("answers");
2083        let (_, rcode, _) = parse_header(&resp);
2084        assert_eq!(
2085            rcode, 2,
2086            "off-tailnet non-IN class, no upstream -> SERVFAIL, not Refused"
2087        );
2088    }
2089
2090    /// A view with MagicDNS on, the `user.ts.net` search domain, and the given split-DNS routes
2091    /// + global resolvers.
2092    fn view_with_routes(
2093        routes: std::collections::BTreeMap<String, Vec<DnsResolver>>,
2094        resolvers: Vec<DnsResolver>,
2095        fallback: Vec<DnsResolver>,
2096    ) -> DnsView {
2097        DnsView {
2098            cfg: DnsConfig {
2099                magic_dns: true,
2100                search_domains: vec!["user.ts.net".to_string()],
2101                routes,
2102                resolvers,
2103                fallback_resolvers: fallback,
2104                ..Default::default()
2105            },
2106            peers: None,
2107            self_node: None,
2108            exit_doh: None,
2109            enable_ipv6: false,
2110            accept_dns: true,
2111        }
2112    }
2113
2114    fn udp(addr: &str) -> DnsResolver {
2115        DnsResolver {
2116            transport: ts_control::ResolverTransport::Udp(addr.parse().unwrap()),
2117            use_with_exit_node: false,
2118        }
2119    }
2120
2121    #[test]
2122    fn split_dns_route_forwards_to_matching_upstream() {
2123        let mut routes = std::collections::BTreeMap::new();
2124        routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
2125        let view = view_with_routes(routes, vec![], vec![]);
2126        let buf = build_query(0x100, &["api", "corp", "example"], 1, 1);
2127
2128        match decide(&view, &buf).expect("decides") {
2129            Decision::Forward { upstreams, .. } => {
2130                assert_eq!(upstreams, vec!["10.0.0.53:53".parse().unwrap()]);
2131            }
2132            Decision::Reply(_) => panic!("expected forward to the split-DNS upstream"),
2133        }
2134    }
2135
2136    #[test]
2137    fn exotic_qtype_off_tailnet_forwards_to_upstream() {
2138        // The core of the fix: an HTTPS/SVCB (type 65) query for an off-tailnet name with a matching
2139        // route must FORWARD to the upstream (verbatim), exactly like an A query would — not REFUSE
2140        // and not NXDOMAIN. This is the browser HTTP/3 + ECH case the old blanket-REFUSE broke.
2141        let mut routes = std::collections::BTreeMap::new();
2142        routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
2143        let view = view_with_routes(routes, vec![], vec![]);
2144        let buf = build_query(0x102, &["api", "corp", "example"], 65, 1);
2145
2146        match decide(&view, &buf).expect("decides") {
2147            Decision::Forward {
2148                upstreams, query, ..
2149            } => {
2150                assert_eq!(upstreams, vec!["10.0.0.53:53".parse().unwrap()]);
2151                assert_eq!(query, buf, "the exotic-qtype query is forwarded verbatim");
2152            }
2153            Decision::Reply(_) => {
2154                panic!("an off-tailnet HTTPS-record query must forward, not reply")
2155            }
2156        }
2157    }
2158
2159    #[test]
2160    fn non_in_class_off_tailnet_forwards_to_upstream() {
2161        // A non-IN class for an off-tailnet routed name forwards too (Go does no class check on the
2162        // local path). Proves the class gate no longer short-circuits to REFUSED before routing.
2163        let mut routes = std::collections::BTreeMap::new();
2164        routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
2165        let view = view_with_routes(routes, vec![], vec![]);
2166        let buf = build_query(0x103, &["api", "corp", "example"], 1, 3);
2167
2168        match decide(&view, &buf).expect("decides") {
2169            Decision::Forward { upstreams, .. } => {
2170                assert_eq!(upstreams, vec!["10.0.0.53:53".parse().unwrap()]);
2171            }
2172            Decision::Reply(_) => {
2173                panic!("an off-tailnet non-IN-class query must forward, not reply")
2174            }
2175        }
2176    }
2177
2178    /// The local responder bounds concurrent in-flight forwards: `serve` acquires one
2179    /// `MAX_INFLIGHT_FORWARDS` permit per spawned forward task and drops the query fail-closed when
2180    /// the pool is exhausted (a client spraying forwardable names can't open unbounded overlay
2181    /// sockets). This pins the gating semantics `serve` relies on — drained pool refuses a new
2182    /// permit; releasing one restores capacity — and the cap constant itself. (The async `serve`
2183    /// loop has no netstack-free test seam, so the semaphore behavior is exercised directly here, the
2184    /// same `Arc<Semaphore>::try_acquire_owned` the loop uses.)
2185    #[test]
2186    fn forward_inflight_cap_fails_closed_when_saturated() {
2187        use std::sync::Arc;
2188
2189        use tokio::sync::Semaphore;
2190
2191        let inflight = Arc::new(Semaphore::new(MAX_INFLIGHT_FORWARDS));
2192
2193        // Drain every permit (one per concurrently in-flight forward).
2194        let mut held = Vec::with_capacity(MAX_INFLIGHT_FORWARDS);
2195        for _ in 0..MAX_INFLIGHT_FORWARDS {
2196            held.push(
2197                inflight
2198                    .clone()
2199                    .try_acquire_owned()
2200                    .expect("permits available below the cap"),
2201            );
2202        }
2203
2204        // At the cap, the next forward is refused — `serve` would drop the query, not spawn.
2205        assert!(
2206            inflight.clone().try_acquire_owned().is_err(),
2207            "a saturated forward pool must refuse a new permit (fail closed)"
2208        );
2209
2210        // Completing an in-flight forward releases its permit and restores capacity.
2211        drop(held.pop());
2212        assert!(
2213            inflight.clone().try_acquire_owned().is_ok(),
2214            "releasing a permit must let the next forward proceed"
2215        );
2216    }
2217
2218    /// A permit moved into a spawned forward task (the `let _permit = permit;` shape `serve` uses)
2219    /// must stay held for the *whole* task body — across the `.await` on the upstream — and release
2220    /// only when the task completes. This guards the regression the saturation test above can't see:
2221    /// "tidying" `let _permit = permit;` to `let _ = permit;` would drop the permit immediately,
2222    /// re-opening unbounded concurrency while leaving the synchronous drain/restore test green. Here a
2223    /// 1-permit pool is consumed by a task that holds it across a yield; the pool must read empty
2224    /// while the task runs and refill once it finishes.
2225    #[tokio::test]
2226    async fn forward_permit_is_held_for_the_task_lifetime_not_dropped_early() {
2227        use std::sync::Arc;
2228
2229        use tokio::sync::Semaphore;
2230
2231        let inflight = Arc::new(Semaphore::new(1));
2232        let permit = inflight
2233            .clone()
2234            .try_acquire_owned()
2235            .expect("the sole permit is available");
2236
2237        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
2238        let (release_tx, release_rx) = tokio::sync::oneshot::channel();
2239        let task = tokio::spawn(async move {
2240            // Same shape as `serve`'s spawned forward: the permit is a named binding moved into the
2241            // task, so it lives until the body ends — not dropped at the `let`.
2242            let _permit = permit;
2243            started_tx.send(()).unwrap();
2244            // Stand in for the `.await` on the upstream forward.
2245            release_rx.await.unwrap();
2246        });
2247
2248        started_rx.await.unwrap();
2249        // While the task runs, the permit it moved in is still held — the pool is empty.
2250        assert!(
2251            inflight.clone().try_acquire_owned().is_err(),
2252            "a permit moved into a running task must stay held across its await"
2253        );
2254
2255        // Let the task finish; its permit drops with the body and capacity returns.
2256        release_tx.send(()).unwrap();
2257        task.await.unwrap();
2258        assert!(
2259            inflight.clone().try_acquire_owned().is_ok(),
2260            "the permit must be released once the task body completes"
2261        );
2262    }
2263
2264    #[test]
2265    fn longest_suffix_route_wins() {
2266        let mut routes = std::collections::BTreeMap::new();
2267        routes.insert("example".to_string(), vec![udp("10.0.0.1:53")]);
2268        routes.insert("corp.example".to_string(), vec![udp("10.0.0.2:53")]);
2269        let view = view_with_routes(routes, vec![], vec![]);
2270        let buf = build_query(0x101, &["api", "corp", "example"], 1, 1);
2271
2272        match decide(&view, &buf).expect("decides") {
2273            Decision::Forward { upstreams, .. } => {
2274                assert_eq!(
2275                    upstreams,
2276                    vec!["10.0.0.2:53".parse().unwrap()],
2277                    "longer suffix wins"
2278                );
2279            }
2280            Decision::Reply(_) => panic!("expected forward"),
2281        }
2282    }
2283
2284    #[test]
2285    fn negative_route_is_nxdomain_not_forwarded() {
2286        // An empty upstream list is a negative route: fail closed, never forward.
2287        let mut routes = std::collections::BTreeMap::new();
2288        routes.insert("blocked.example".to_string(), vec![]);
2289        let view = view_with_routes(routes, vec![udp("8.8.8.8:53")], vec![]);
2290        let buf = build_query(0x102, &["x", "blocked", "example"], 1, 1);
2291
2292        match decide(&view, &buf).expect("decides") {
2293            Decision::Reply(resp) => {
2294                let (_, rcode, _) = parse_header(&resp);
2295                assert_eq!(rcode, 3, "NxDomain: negative route is not forwarded");
2296            }
2297            Decision::Forward { .. } => panic!("negative route must not forward"),
2298        }
2299    }
2300
2301    #[test]
2302    fn unrouted_name_forwards_to_fallback_then_global() {
2303        // No route matches: fallback resolvers are preferred over global resolvers.
2304        let view = view_with_routes(
2305            std::collections::BTreeMap::new(),
2306            vec![udp("8.8.8.8:53")],
2307            vec![udp("1.1.1.1:53")],
2308        );
2309        let buf = build_query(0x103, &["example", "com"], 1, 1);
2310
2311        match decide(&view, &buf).expect("decides") {
2312            Decision::Forward { upstreams, .. } => {
2313                assert_eq!(
2314                    upstreams,
2315                    vec!["1.1.1.1:53".parse().unwrap()],
2316                    "fallback preferred"
2317                );
2318            }
2319            Decision::Reply(_) => panic!("expected forward to fallback"),
2320        }
2321    }
2322
2323    #[test]
2324    fn unrouted_name_forwards_to_global_when_no_fallback() {
2325        let view = view_with_routes(
2326            std::collections::BTreeMap::new(),
2327            vec![udp("8.8.8.8:53")],
2328            vec![],
2329        );
2330        let buf = build_query(0x104, &["example", "com"], 1, 1);
2331
2332        match decide(&view, &buf).expect("decides") {
2333            Decision::Forward { upstreams, .. } => {
2334                assert_eq!(upstreams, vec!["8.8.8.8:53".parse().unwrap()]);
2335            }
2336            Decision::Reply(_) => panic!("expected forward to global resolver"),
2337        }
2338    }
2339
2340    #[test]
2341    fn tailnet_name_is_never_forwarded() {
2342        // Anti-leak: a name under a tailnet search domain that has no overlay match must fail
2343        // closed to NXDOMAIN, never leak to an upstream resolver, even with resolvers configured.
2344        let view = view_with_routes(
2345            std::collections::BTreeMap::new(),
2346            vec![udp("8.8.8.8:53")],
2347            vec![udp("1.1.1.1:53")],
2348        );
2349        // "ghost.user.ts.net" is under the tailnet suffix but matches no peer.
2350        let buf = build_query(0x105, &["ghost", "user", "ts", "net"], 1, 1);
2351
2352        match decide(&view, &buf).expect("decides") {
2353            Decision::Reply(resp) => {
2354                let (_, rcode, _) = parse_header(&resp);
2355                assert_eq!(rcode, 3, "NxDomain: tailnet name not leaked upstream");
2356            }
2357            Decision::Forward { .. } => panic!("tailnet name must never be forwarded"),
2358        }
2359    }
2360
2361    #[test]
2362    fn no_resolvers_off_tailnet_is_servfail_not_nxdomain() {
2363        // No route, no resolvers: an OFF-tailnet name cannot be forwarded. Go answers SERVFAIL
2364        // (forwarder.go:1207 "no upstream resolvers set, returning SERVFAIL"), NOT NXDOMAIN — a
2365        // cacheable non-existence for a real name we merely couldn't forward would poison downstream
2366        // stub caches. We still never forward (the name does not leak); we just soft-fail.
2367        let view = view_with_routes(std::collections::BTreeMap::new(), vec![], vec![]);
2368        let buf = build_query(0x106, &["example", "com"], 1, 1);
2369
2370        match decide(&view, &buf).expect("decides") {
2371            Decision::Reply(resp) => {
2372                let (_, rcode, _) = parse_header(&resp);
2373                assert_eq!(
2374                    rcode, 2,
2375                    "ServFail: off-tailnet name with no upstream to forward to"
2376                );
2377            }
2378            Decision::Forward { .. } => panic!("must not forward with no resolvers"),
2379        }
2380    }
2381
2382    #[test]
2383    fn route_with_only_ipv6_upstreams_off_tailnet_is_servfail() {
2384        // A split-DNS route exists but every resolver is IPv6 (filtered out under the IPv4-only
2385        // egress): we have a route yet nowhere to forward. That is an inability to forward an
2386        // off-tailnet name, so SERVFAIL (soft), not a fabricated NXDOMAIN.
2387        let mut routes = std::collections::BTreeMap::new();
2388        routes.insert("corp.example".to_string(), vec![udp("[2001:db8::53]:53")]);
2389        let view = view_with_routes(routes, vec![], vec![]);
2390        let buf = build_query(0x108, &["host", "corp", "example"], 1, 1);
2391
2392        match decide(&view, &buf).expect("decides") {
2393            Decision::Reply(resp) => {
2394                let (_, rcode, _) = parse_header(&resp);
2395                assert_eq!(
2396                    rcode, 2,
2397                    "ServFail: route's resolvers all filtered out (IPv6-only), cannot forward"
2398                );
2399            }
2400            Decision::Forward { .. } => panic!("must not forward when all upstreams are filtered"),
2401        }
2402    }
2403
2404    #[test]
2405    fn overlay_match_wins_over_forwarding() {
2406        // A known peer name resolves authoritatively even when upstream resolvers are configured.
2407        let mut db = PeerDb::default();
2408        db.upsert(&test_node());
2409        let view = DnsView {
2410            cfg: DnsConfig {
2411                magic_dns: true,
2412                search_domains: vec!["user.ts.net".to_string()],
2413                resolvers: vec![udp("8.8.8.8:53")],
2414                ..Default::default()
2415            },
2416            peers: Some(Arc::new(db)),
2417            self_node: None,
2418            exit_doh: None,
2419            enable_ipv6: false,
2420            accept_dns: true,
2421        };
2422        let buf = build_query(0x107, &["host", "user", "ts", "net"], 1, 1);
2423
2424        match decide(&view, &buf).expect("decides") {
2425            Decision::Reply(resp) => {
2426                let (_, rcode, ancount) = parse_header(&resp);
2427                assert_eq!(rcode, 0, "authoritative answer wins");
2428                assert_eq!(ancount, 1);
2429            }
2430            Decision::Forward { .. } => panic!("overlay match must not forward"),
2431        }
2432    }
2433
2434    #[test]
2435    fn ipv6_reverse_ptr_is_nxdomain_not_forwarded() {
2436        // Anti-leak: an `ip6.arpa` reverse PTR for a tailnet ULA (fd7a:…) must fail closed to
2437        // NXDOMAIN, never be forwarded — even with an upstream resolver configured. This fork is
2438        // IPv4-only on the tailnet; forwarding would reveal that a v6 address was probed.
2439        let view = view_with_routes(
2440            std::collections::BTreeMap::new(),
2441            vec![udp("8.8.8.8:53")],
2442            vec![udp("1.1.1.1:53")],
2443        );
2444        // Reverse name for fd7a::1 (nibble-reversed) under ip6.arpa. The exact nibble labels don't
2445        // matter to the guard — any name ending in ip6.arpa must fail closed.
2446        let labels = vec![
2447            "1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0",
2448            "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "a", "7", "d", "f", "ip6",
2449            "arpa",
2450        ];
2451        let buf = build_query(0x200, &labels, 12, 1);
2452
2453        match decide(&view, &buf).expect("decides") {
2454            Decision::Reply(resp) => {
2455                let (_, rcode, _) = parse_header(&resp);
2456                assert_eq!(
2457                    rcode, 3,
2458                    "NxDomain: ip6.arpa reverse must not leak upstream"
2459                );
2460            }
2461            Decision::Forward { .. } => panic!("ip6.arpa PTR must never be forwarded"),
2462        }
2463    }
2464
2465    #[test]
2466    fn cap_response_sets_tc_when_truncated() {
2467        // An oversize upstream answer is capped to a single datagram AND marked truncated (TC bit)
2468        // so the stub resolver retries over TCP rather than trusting a chopped message. The query
2469        // advertises a big EDNS buffer so only the relay cap can be what fires here.
2470        let query = build_edns_query(0x300, &["example", "com"], 1, 1, 4096);
2471        let mut big = query.clone();
2472        big[2] |= 0x80; // make it a response (QR=1)
2473        big.resize(MAX_UPSTREAM_RESPONSE + 500, 0xAB);
2474
2475        let out = cap_response(&query, big);
2476        assert_eq!(out.len(), MAX_UPSTREAM_RESPONSE, "capped to one datagram");
2477        assert_ne!(out[2] & 0x02, 0, "TC bit set on truncation");
2478    }
2479
2480    #[test]
2481    fn cap_response_leaves_small_response_untouched() {
2482        // A response that fits both bounds is returned verbatim with no TC bit forced on.
2483        let query = build_query(0x301, &["example", "com"], 1, 1);
2484        let mut small = query.clone();
2485        small[2] |= 0x80;
2486        let before = small.clone();
2487
2488        let out = cap_response(&query, small);
2489        assert_eq!(out, before, "small response unchanged");
2490        assert_eq!(out[2] & 0x02, 0, "TC bit not set when no truncation");
2491    }
2492
2493    #[test]
2494    fn cap_is_a_relay_bound_not_the_read_bound() {
2495        // `forward_query` reads with `recv_from_bytes`, which issues `Recv { max_len: None }`, so
2496        // the netstack has already copied the whole datagram out before `cap_response` runs: the
2497        // cap bounds what we relay, not what we read or allocate. What bounds the read is the
2498        // netstack UDP socket's receive ring (`udp_buffer_size`, which `ts_runtime` leaves at the
2499        // `netcore` default) -- smoltcp drops a datagram larger than that ring at enqueue instead
2500        // of delivering it. Pin the consequence: the largest answer this socket can deliver is
2501        // relayed byte-for-byte, so the truncate-and-chop branch never fires on the forwarded path.
2502        // Ask with an EDNS buffer that covers the whole datagram, so the client-limit check (the
2503        // other half of `cap_response`) is not what we are measuring.
2504        let ring = netstack::netcore::Config::default().udp_buffer_size;
2505        assert!(
2506            MAX_UPSTREAM_RESPONSE >= ring,
2507            "cap ({MAX_UPSTREAM_RESPONSE}) is below the netstack udp receive ring ({ring}): the cap \
2508             would then be what truncates a deliverable answer, and the docs saying otherwise are \
2509             wrong"
2510        );
2511
2512        let query = build_edns_query(0x302, &["example", "com"], 1, 1, 4096);
2513        let mut largest = query.clone();
2514        largest[2] |= 0x80; // QR=1
2515        largest.resize(ring, 0xAB);
2516        let before = largest.clone();
2517
2518        let out = cap_response(&query, largest);
2519        assert_eq!(
2520            out, before,
2521            "the largest deliverable datagram must be relayed verbatim"
2522        );
2523        assert_eq!(
2524            out[2] & 0x02,
2525            0,
2526            "TC must not be set on a datagram that was never chopped"
2527        );
2528    }
2529
2530    #[test]
2531    fn forwarded_reply_over_512_sets_tc_for_a_plain_query() {
2532        // A query with no EDNS OPT record is limited to 512 bytes (RFC 1035), so a 900-byte
2533        // forwarded reply -- well under the 4096 relay cap, and therefore relayed with TC clear
2534        // before this check existed -- must come back marked truncated, body intact.
2535        let query = build_query(0x400, &["example", "com"], 1, 1);
2536        let mut reply = query.clone();
2537        reply[2] |= 0x80; // QR=1
2538        reply.resize(900, 0xAB);
2539
2540        let out = cap_response(&query, reply.clone());
2541
2542        assert_ne!(
2543            out[2] & 0x02,
2544            0,
2545            "a 900-byte reply to a non-EDNS query must have TC set"
2546        );
2547        assert_eq!(out.len(), 900, "the body is left intact, not chopped");
2548        assert_eq!(
2549            out[3..],
2550            reply[3..],
2551            "only the flags byte carrying TC may differ"
2552        );
2553    }
2554
2555    #[test]
2556    fn forwarded_reply_under_advertised_edns_size_leaves_tc_clear() {
2557        // The same 900-byte reply, but the client advertised a 4096-byte EDNS buffer: it fits, so
2558        // TC must stay clear and the datagram must be relayed byte-for-byte.
2559        let query = build_edns_query(0x401, &["example", "com"], 1, 1, 4096);
2560        let mut reply = query.clone();
2561        reply[2] |= 0x80; // QR=1
2562        reply.resize(900, 0xAB);
2563        let before = reply.clone();
2564
2565        let out = cap_response(&query, reply);
2566
2567        assert_eq!(
2568            out, before,
2569            "a reply within the advertised buffer is verbatim"
2570        );
2571        assert_eq!(out[2] & 0x02, 0, "TC must stay clear");
2572    }
2573
2574    #[test]
2575    fn client_udp_limit_reads_the_opt_record() {
2576        // No OPT record => the RFC 1035 512-byte limit.
2577        let plain = build_query(0x402, &["example", "com"], 1, 1);
2578        assert_eq!(client_udp_limit(&plain), NO_EDNS_UDP_LIMIT);
2579
2580        // An OPT record's CLASS field carries the advertised size.
2581        let edns = build_edns_query(0x403, &["example", "com"], 1, 1, 1232);
2582        assert_eq!(client_udp_limit(&edns), 1232);
2583
2584        // RFC 6891 6.2.3: a value below 512 is treated as 512, never as a smaller limit.
2585        let tiny = build_edns_query(0x404, &["example", "com"], 1, 1, 64);
2586        assert_eq!(client_udp_limit(&tiny), NO_EDNS_UDP_LIMIT);
2587
2588        // A non-OPT record ahead of the OPT one in the additional section is stepped over, not
2589        // mistaken for it.
2590        let mut two_rrs = build_edns_query(0x405, &["example", "com"], 1, 1, 2048);
2591        let opt = two_rrs.split_off(two_rrs.len() - 11);
2592        // A 1-byte-RDATA TXT (type 16) record for the root name, spliced in before the OPT.
2593        two_rrs.extend_from_slice(&[0, 0, 16, 0, 1, 0, 0, 0, 0, 0, 1, 0]);
2594        two_rrs.extend_from_slice(&opt);
2595        two_rrs[11] = 2; // ARCOUNT = 2
2596        assert_eq!(client_udp_limit(&two_rrs), 2048);
2597
2598        // A truncated / unwalkable message falls back to the conservative limit, never a larger one.
2599        let mut chopped = build_edns_query(0x406, &["example", "com"], 1, 1, 4096);
2600        chopped.truncate(chopped.len() - 8);
2601        assert_eq!(client_udp_limit(&chopped), NO_EDNS_UDP_LIMIT);
2602    }
2603
2604    #[test]
2605    fn response_matches_query_rejects_mismatched_question() {
2606        // id + QR match but the echoed question differs (different QNAME) => rejected. This guards
2607        // against an off-path injector that guesses the id but answers a different question.
2608        let query = build_query(0x1234, &["a", "com"], 1, 1);
2609
2610        let mut wrong_question = build_query(0x1234, &["b", "com"], 1, 1);
2611        wrong_question[2] |= 0x80; // QR=1, same id
2612        assert!(
2613            !response_matches_query(&query, &wrong_question),
2614            "different QNAME must be rejected"
2615        );
2616
2617        // A different QTYPE with the same name is also rejected.
2618        let mut wrong_qtype = build_query(0x1234, &["a", "com"], 28, 1);
2619        wrong_qtype[2] |= 0x80;
2620        assert!(
2621            !response_matches_query(&query, &wrong_qtype),
2622            "different QTYPE must be rejected"
2623        );
2624
2625        // The exact echoed question with QR=1 is accepted.
2626        let mut good = query.clone();
2627        good[2] |= 0x80;
2628        assert!(
2629            response_matches_query(&query, &good),
2630            "matching question accepted"
2631        );
2632    }
2633
2634    #[test]
2635    fn suffix_matches_handles_boundaries_and_empty() {
2636        // Exact and label-boundary matches.
2637        assert!(suffix_matches("corp", "corp"));
2638        assert!(suffix_matches("a.corp", "corp"));
2639        assert!(suffix_matches("a.b.corp", "corp"));
2640        // Not a label boundary.
2641        assert!(!suffix_matches("acorp", "corp"));
2642        // Empty suffix never matches (defense-in-depth against `ends_with("")`).
2643        assert!(!suffix_matches("anything.example", ""));
2644        assert!(!suffix_matches("", ""));
2645    }
2646
2647    #[test]
2648    fn empty_search_domain_does_not_capture_everything() {
2649        // Defense-in-depth: an empty search domain must NOT make every name look like a tailnet
2650        // name (which would fail-close legitimate recursive queries / mis-route). With an empty
2651        // suffix present alongside a real resolver, an off-tailnet name still forwards.
2652        let mut view = view_with_routes(
2653            std::collections::BTreeMap::new(),
2654            vec![udp("8.8.8.8:53")],
2655            vec![],
2656        );
2657        view.cfg.search_domains = vec![String::new()];
2658        let buf = build_query(0x400, &["example", "com"], 1, 1);
2659
2660        match decide(&view, &buf).expect("decides") {
2661            Decision::Forward { upstreams, .. } => {
2662                assert_eq!(upstreams, vec!["8.8.8.8:53".parse().unwrap()]);
2663            }
2664            Decision::Reply(_) => {
2665                panic!("empty search domain must not treat every name as tailnet")
2666            }
2667        }
2668    }
2669
2670    #[test]
2671    fn empty_route_suffix_does_not_capture_everything() {
2672        // Defense-in-depth: an empty route suffix must not match every name (which would route all
2673        // queries to that route's upstreams). With an empty-suffix route present, an unrelated name
2674        // still falls through to the global resolver.
2675        let mut routes = std::collections::BTreeMap::new();
2676        routes.insert(String::new(), vec![udp("10.9.9.9:53")]);
2677        let view = view_with_routes(routes, vec![udp("8.8.8.8:53")], vec![]);
2678        let buf = build_query(0x401, &["example", "com"], 1, 1);
2679
2680        match decide(&view, &buf).expect("decides") {
2681            Decision::Forward { upstreams, .. } => {
2682                assert_eq!(
2683                    upstreams,
2684                    vec!["8.8.8.8:53".parse().unwrap()],
2685                    "empty route suffix must not capture; falls through to global"
2686                );
2687            }
2688            Decision::Reply(_) => panic!("expected forward to global resolver"),
2689        }
2690    }
2691
2692    fn udp_exit(addr: &str) -> DnsResolver {
2693        DnsResolver {
2694            transport: ts_control::ResolverTransport::Udp(addr.parse().unwrap()),
2695            use_with_exit_node: true,
2696        }
2697    }
2698
2699    #[test]
2700    fn recursive_forward_is_flagged_route_forward_is_not() {
2701        // A recursive (global/fallback) forward sets `recursive = true` (eligible for DoH
2702        // delegation); a deliberately-configured split-DNS route sets `recursive = false`.
2703        let mut routes = std::collections::BTreeMap::new();
2704        routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
2705        let view = view_with_routes(routes, vec![udp("8.8.8.8:53")], vec![]);
2706
2707        let routed = build_query(0x500, &["api", "corp", "example"], 1, 1);
2708        match decide(&view, &routed).expect("decides") {
2709            Decision::Forward { recursive, .. } => {
2710                assert!(!recursive, "split-DNS route is not a recursive forward")
2711            }
2712            Decision::Reply(_) => panic!("expected route forward"),
2713        }
2714
2715        let global = build_query(0x501, &["example", "com"], 1, 1);
2716        match decide(&view, &global).expect("decides") {
2717            Decision::Forward { recursive, .. } => {
2718                assert!(recursive, "unrouted name is a recursive forward")
2719            }
2720            Decision::Reply(_) => panic!("expected recursive forward"),
2721        }
2722    }
2723
2724    #[test]
2725    fn recursive_plan_keeps_udp_without_exit_node() {
2726        // No active exit node: a recursive forward stays on its default UDP upstreams.
2727        let view = view_with_routes(
2728            std::collections::BTreeMap::new(),
2729            vec![udp("8.8.8.8:53")],
2730            vec![],
2731        );
2732        let default = vec!["8.8.8.8:53".parse().unwrap()];
2733        assert_eq!(
2734            recursive_plan(&view, default.clone()),
2735            RecursivePlan::Udp(default)
2736        );
2737    }
2738
2739    #[test]
2740    fn recursive_plan_delegates_to_doh_with_exit_node() {
2741        // Exit node active, no kept-local resolvers: recursive queries delegate to the exit node's
2742        // DoH endpoint so resolution egresses from the exit node, not this host.
2743        let mut view = view_with_routes(
2744            std::collections::BTreeMap::new(),
2745            vec![udp("8.8.8.8:53")],
2746            vec![],
2747        );
2748        let doh: SocketAddr = "100.64.0.5:8080".parse().unwrap();
2749        view.exit_doh = Some(doh);
2750        assert_eq!(
2751            recursive_plan(&view, vec!["8.8.8.8:53".parse().unwrap()]),
2752            RecursivePlan::Doh(doh)
2753        );
2754    }
2755
2756    #[test]
2757    fn recursive_plan_keeps_use_with_exit_node_resolvers_local() {
2758        // Even with an exit node active, resolvers flagged `use_with_exit_node` stay local (Go keeps
2759        // UseWithExitNode resolvers). The plan forwards to those over UDP, never delegating to DoH.
2760        let mut view = view_with_routes(
2761            std::collections::BTreeMap::new(),
2762            vec![udp_exit("10.0.0.53:53"), udp("8.8.8.8:53")],
2763            vec![],
2764        );
2765        view.exit_doh = Some("100.64.0.5:8080".parse().unwrap());
2766        // The default upstreams the caller computed are irrelevant when kept-local resolvers exist;
2767        // the plan must use the kept-local ones.
2768        assert_eq!(
2769            recursive_plan(&view, vec!["8.8.8.8:53".parse().unwrap()]),
2770            RecursivePlan::Udp(vec!["10.0.0.53:53".parse().unwrap()])
2771        );
2772    }
2773
2774    // --- SOA on authoritative negative answers (RFC 2308) -----------------------------------
2775
2776    /// Read an uncompressed name at `off`, returning it dotted and the offset just past it.
2777    fn read_name(resp: &[u8], mut off: usize) -> (String, usize) {
2778        let mut labels: Vec<String> = Vec::new();
2779        loop {
2780            let len = resp[off] as usize;
2781            assert_eq!(len & 0xC0, 0, "no compression pointer expected here");
2782            off += 1;
2783            if len == 0 {
2784                break;
2785            }
2786            labels.push(String::from_utf8(resp[off..off + len].to_vec()).expect("ascii label"));
2787            off += len;
2788        }
2789        (labels.join("."), off)
2790    }
2791
2792    /// The number of records in a response's authority section (NSCOUNT).
2793    fn nscount(resp: &[u8]) -> u16 {
2794        u16::from_be_bytes([resp[8], resp[9]])
2795    }
2796
2797    /// Walk an answer-less response to its authority section and read the SOA there, returning
2798    /// `(zone, record TTL, SERIAL, MINIMUM)`. `None` when the authority section is empty.
2799    ///
2800    /// Also asserts the record's shape as it goes: TYPE=SOA, CLASS=IN, and MNAME/RNAME both equal
2801    /// the owner name (the placeholders Go writes).
2802    fn parse_soa(resp: &[u8]) -> Option<(String, u32, u32, u32)> {
2803        let (.., ancount) = parse_header(resp);
2804        assert_eq!(ancount, 0, "parse_soa only walks answer-less responses");
2805        if nscount(resp) == 0 {
2806            return None;
2807        }
2808        assert_eq!(nscount(resp), 1, "at most one SOA");
2809
2810        // Question: QNAME then QTYPE + QCLASS.
2811        let (_, off) = read_name(resp, 12);
2812        // Authority record: NAME, TYPE, CLASS, TTL, RDLENGTH, RDATA.
2813        let (zone, off) = read_name(resp, off + 4);
2814        let u16_at = |at: usize| u16::from_be_bytes([resp[at], resp[at + 1]]);
2815        let u32_at = |at: usize| u32::from_be_bytes(resp[at..at + 4].try_into().unwrap());
2816        assert_eq!(u16_at(off), 6, "TYPE = SOA");
2817        assert_eq!(u16_at(off + 2), 1, "CLASS = IN");
2818        let ttl = u32_at(off + 4);
2819        let rdlength = u16_at(off + 8) as usize;
2820
2821        // RDATA: MNAME, RNAME, SERIAL, REFRESH, RETRY, EXPIRE, MINIMUM.
2822        let rdata_start = off + 10;
2823        let (mname, off) = read_name(resp, rdata_start);
2824        let (rname, off) = read_name(resp, off);
2825        assert_eq!(mname, zone, "MNAME is the zone (placeholder)");
2826        assert_eq!(rname, zone, "RNAME is the zone (placeholder)");
2827        let serial = u32_at(off);
2828        let minimum = u32_at(off + 16);
2829        assert_eq!(
2830            off + 20 - rdata_start,
2831            rdlength,
2832            "RDLENGTH covers exactly the SOA fields"
2833        );
2834        assert_eq!(resp.len(), off + 20, "the SOA is the last record");
2835        Some((zone, ttl, serial, minimum))
2836    }
2837
2838    /// Roughly-now, for asserting the SOA SERIAL is a unix timestamp rather than a constant.
2839    fn now_unix() -> u32 {
2840        std::time::SystemTime::now()
2841            .duration_since(std::time::UNIX_EPOCH)
2842            .expect("clock after the epoch")
2843            .as_secs() as u32
2844    }
2845
2846    /// An NXDOMAIN for a name under a tailnet search domain is authoritative, so it carries that
2847    /// search domain's SOA with the 10-second negative TTL. Without it a downstream cache picks its
2848    /// own (much longer) negative lifetime and a node renamed to that name stays unresolvable.
2849    #[test]
2850    fn nxdomain_for_tailnet_name_carries_the_search_domain_soa() {
2851        let view = view_with_peer();
2852        let buf = build_query(0x1111, &["nope", "user", "ts", "net"], 1, 1);
2853
2854        let resp = answer(&view, &buf).expect("answers");
2855        let (_, rcode, ancount) = parse_header(&resp);
2856        assert_eq!(rcode, 3, "NXDOMAIN");
2857        assert_eq!(ancount, 0);
2858
2859        let (zone, ttl, serial, minimum) =
2860            parse_soa(&resp).expect("an SOA in the authority section");
2861        assert_eq!(zone, "user.ts.net", "the search domain containing the name");
2862        assert_eq!(ttl, 10, "negative TTL");
2863        assert_eq!(minimum, 10, "MINIMUM also bounds negative caching");
2864        // The serial is the response time in unix seconds, not a fixed placeholder.
2865        assert!(
2866            serial.abs_diff(now_unix()) < 60,
2867            "SERIAL should be about now, got {serial}"
2868        );
2869    }
2870
2871    /// A NODATA — the name exists but we hold no address of the queried family, which is what an
2872    /// AAAA query for a peer becomes with the IPv6 gate off — is negative too, and takes the SOA.
2873    #[test]
2874    fn nodata_aaaa_for_known_peer_carries_the_soa() {
2875        let view = view_with_peer();
2876        assert!(!view.enable_ipv6, "default gate is off");
2877        let buf = build_query(0x2222, &["host", "user", "ts", "net"], 28, 1);
2878
2879        let resp = answer(&view, &buf).expect("answers");
2880        let (_, rcode, ancount) = parse_header(&resp);
2881        assert_eq!(rcode, 0, "NoError (NODATA)");
2882        assert_eq!(ancount, 0);
2883        let (zone, ttl, _, minimum) = parse_soa(&resp).expect("an SOA in the authority section");
2884        assert_eq!(zone, "user.ts.net");
2885        assert_eq!((ttl, minimum), (10, 10));
2886    }
2887
2888    /// A reverse query for an unmatched IP in the tailnet CGNAT range is authoritatively absent, so
2889    /// it carries the SOA of the reverse zone that covers it — the same per-/16 `in-addr.arpa`
2890    /// chunk real tailscaled advertises, not the search domain.
2891    #[test]
2892    fn cgnat_reverse_miss_carries_the_reverse_zone_soa() {
2893        let view = view_with_peer();
2894        // Reverse name for an unclaimed 100.64.0.0/10 address, least-significant octet first.
2895        let buf = build_query(0x3333, &["9", "0", "64", "100", "in-addr", "arpa"], 12, 1);
2896
2897        let resp = answer(&view, &buf).expect("answers");
2898        let (_, rcode, ancount) = parse_header(&resp);
2899        assert_eq!(rcode, 3, "NXDOMAIN");
2900        assert_eq!(ancount, 0);
2901        let (zone, ttl, _, minimum) = parse_soa(&resp).expect("an SOA in the authority section");
2902        assert_eq!(zone, "64.100.in-addr.arpa", "the CGNAT reverse zone");
2903        assert_eq!((ttl, minimum), (10, 10));
2904    }
2905
2906    /// The exotic-qtype path re-applies the CGNAT reverse guard, and its NXDOMAIN is just as
2907    /// authoritative — so it carries the same reverse-zone SOA the PTR arm does.
2908    #[test]
2909    fn exotic_qtype_cgnat_reverse_nxdomain_carries_the_soa() {
2910        let view = view_with_peer();
2911        // TXT (16) for a CGNAT reverse name.
2912        let buf = build_query(0x4444, &["9", "0", "64", "100", "in-addr", "arpa"], 16, 1);
2913
2914        let resp = answer(&view, &buf).expect("answers");
2915        assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
2916        let (zone, ..) = parse_soa(&resp).expect("an SOA in the authority section");
2917        assert_eq!(zone, "64.100.in-addr.arpa");
2918    }
2919
2920    /// A negative split-DNS route (a route with no resolvers) is Go's `localDomains` verbatim: the
2921    /// NXDOMAIN it produces is authoritative and names the route's own suffix as its zone.
2922    #[test]
2923    fn negative_route_nxdomain_carries_the_route_zone_soa() {
2924        let mut routes = std::collections::BTreeMap::new();
2925        routes.insert("corp.example".to_string(), vec![]);
2926        let view = view_with_routes(routes, vec![], vec![]);
2927        let buf = build_query(0x5555, &["intranet", "corp", "example"], 1, 1);
2928
2929        let resp = answer(&view, &buf).expect("answers");
2930        assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
2931        let (zone, ttl, _, minimum) = parse_soa(&resp).expect("an SOA in the authority section");
2932        assert_eq!(zone, "corp.example");
2933        assert_eq!((ttl, minimum), (10, 10));
2934    }
2935
2936    /// Answers we are NOT authoritative for carry no SOA: a SERVFAIL is a soft failure with nothing
2937    /// to cache, and an `ip6.arpa` NXDOMAIN is this fork's blanket anti-leak refusal, not a claim to
2938    /// serve the IPv6 reverse tree.
2939    #[test]
2940    fn non_authoritative_negative_answers_carry_no_soa() {
2941        let view = view_with_peer();
2942
2943        // Off-tailnet name, no upstream configured => SERVFAIL.
2944        let servfail =
2945            answer(&view, &build_query(0x6, &["example", "com"], 1, 1)).expect("answers");
2946        assert_eq!(parse_header(&servfail).1, 2, "ServFail");
2947        assert_eq!(nscount(&servfail), 0, "SERVFAIL carries no SOA");
2948
2949        // An ip6.arpa reverse name. The exact nibble labels do not matter to the guard.
2950        let mut labels: Vec<&str> = vec!["1"; 32];
2951        labels.push("ip6");
2952        labels.push("arpa");
2953        let ip6 = answer(&view, &build_query(0x7, &labels, 12, 1)).expect("answers");
2954        assert_eq!(parse_header(&ip6).1, 3, "NXDOMAIN");
2955        assert_eq!(nscount(&ip6), 0, "ip6.arpa NXDOMAIN carries no SOA");
2956
2957        // MagicDNS off => REFUSED, which asserts nothing about the name.
2958        let mut off = view_with_peer();
2959        off.cfg.magic_dns = false;
2960        let refused = answer(
2961            &off,
2962            &build_query(0x8, &["host", "user", "ts", "net"], 1, 1),
2963        )
2964        .expect("answers");
2965        assert_eq!(parse_header(&refused).1, 5, "Refused");
2966        assert_eq!(nscount(&refused), 0, "REFUSED carries no SOA");
2967    }
2968
2969    /// A NODATA for a type we simply do not serve on a name we do (TXT on a tailnet name) carries
2970    /// no SOA: Go sets `SOAZone` on a no-data answer only for an A/AAAA/ALL question.
2971    #[test]
2972    fn nodata_for_an_unserved_qtype_carries_no_soa() {
2973        let view = view_with_peer();
2974        let resp = answer(
2975            &view,
2976            &build_query(0x9, &["host", "user", "ts", "net"], 16, 1),
2977        )
2978        .expect("answers");
2979        let (_, rcode, ancount) = parse_header(&resp);
2980        assert_eq!((rcode, ancount), (0, 0), "NODATA");
2981        assert_eq!(nscount(&resp), 0);
2982    }
2983
2984    /// A positive answer has an empty authority section and a 5-second TTL. The short TTL is the
2985    /// positive half of the same argument: the netmap is local and in-memory, so a re-query is
2986    /// nearly free, while a downstream cache would otherwise hide a node rename for the full TTL.
2987    #[test]
2988    fn positive_answer_has_ttl_5_and_no_authority_section() {
2989        let view = view_with_peer();
2990        let resp = answer(
2991            &view,
2992            &build_query(0xA, &["host", "user", "ts", "net"], 1, 1),
2993        )
2994        .expect("answers");
2995        let (_, rcode, ancount) = parse_header(&resp);
2996        assert_eq!((rcode, ancount), (0, 1), "one A record");
2997        assert_eq!(nscount(&resp), 0, "a positive answer claims no zone");
2998        // The single A record's tail is TTL, RDLENGTH, RDATA.
2999        let ttl_at = resp.len() - 10;
3000        let ttl = u32::from_be_bytes(resp[ttl_at..ttl_at + 4].try_into().unwrap());
3001        assert_eq!(ttl, 5, "positive TTL");
3002    }
3003
3004    /// An authoritative negative answer with its SOA attached must still fit the classic 512-byte
3005    /// UDP limit, so the forwarded-path client-limit check leaves TC clear on it. That check floors
3006    /// the client's limit at 512, so this holds for a plain query and an EDNS one alike.
3007    #[test]
3008    fn nxdomain_with_soa_stays_within_the_client_udp_limit() {
3009        let view = view_with_peer();
3010        let long = "a".repeat(63);
3011        let buf = build_query(0xB, &[&long, "user", "ts", "net"], 1, 1);
3012
3013        let resp = answer(&view, &buf).expect("answers");
3014        assert_eq!(nscount(&resp), 1, "the SOA fits beside this question");
3015        assert!(resp.len() <= 512, "still one classic UDP datagram");
3016
3017        let marked = set_tc_if_over_client_limit(&buf, resp.clone());
3018        assert_eq!(marked, resp, "nothing to mark: an authoritative reply fits");
3019        assert_eq!(
3020            u16::from_be_bytes([marked[2], marked[3]]) & 0x0200,
3021            0,
3022            "TC must stay clear"
3023        );
3024    }
3025
3026    /// When the zone is so long that its SOA no longer fits under the 512-byte cap, the SOA is
3027    /// dropped rather than the answer being truncated: the NXDOMAIN goes back complete, with an
3028    /// empty authority section, TC clear, and still within a client's UDP limit. Losing the SOA
3029    /// only means a resolver falls back to its own negative-cache policy.
3030    #[test]
3031    fn an_soa_that_will_not_fit_is_dropped_and_the_nxdomain_still_answers() {
3032        let long = "a".repeat(63);
3033        let zone = [long.as_str(), long.as_str(), long.as_str()].join(".");
3034        let mut view = view_with_peer();
3035        view.cfg.search_domains = vec![zone.clone()];
3036
3037        let buf = build_query(0xC, &["x", &long, &long, &long], 1, 1);
3038        let resp = answer(&view, &buf).expect("answers");
3039
3040        assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
3041        assert_eq!(nscount(&resp), 0, "the SOA did not fit and was dropped");
3042        assert!(resp.len() <= 512, "response stays within the UDP limit");
3043        let marked = set_tc_if_over_client_limit(&buf, resp.clone());
3044        assert_eq!(
3045            u16::from_be_bytes([marked[2], marked[3]]) & 0x0200,
3046            0,
3047            "a dropped SOA must not set TC: the fork cannot serve the TCP retry it would ask for"
3048        );
3049    }
3050
3051    /// The zone is the *longest* authoritative suffix containing the name, so a name under a
3052    /// sub-zone gets the sub-zone's SOA rather than the shorter search domain's.
3053    #[test]
3054    fn the_longest_authoritative_zone_wins() {
3055        let mut routes = std::collections::BTreeMap::new();
3056        routes.insert("sub.user.ts.net".to_string(), vec![]);
3057        let mut view = view_with_routes(routes, vec![], vec![]);
3058        view.cfg.search_domains = vec!["user.ts.net".to_string()];
3059
3060        let buf = build_query(0xD, &["nope", "sub", "user", "ts", "net"], 1, 1);
3061        let resp = answer(&view, &buf).expect("answers");
3062        assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
3063        let (zone, ..) = parse_soa(&resp).expect("an SOA in the authority section");
3064        assert_eq!(zone, "sub.user.ts.net");
3065    }
3066
3067    /// A name we resolved only by search-domain qualification (a short name like `host`) is not
3068    /// itself inside a zone we serve, so its negative answer names no zone — matching Go, whose
3069    /// `authoritativeZoneFor` is given the query name as asked.
3070    #[test]
3071    fn a_short_name_outside_every_zone_gets_no_soa() {
3072        let mut view = view_with_peer();
3073        view.enable_ipv6 = false;
3074        // `host` resolves to the peer via search-domain qualification, and with IPv6 off the AAAA
3075        // is a NODATA — but `host` sits under no zone we serve.
3076        let resp = answer(&view, &build_query(0xE, &["host"], 28, 1)).expect("answers");
3077        let (_, rcode, ancount) = parse_header(&resp);
3078        assert_eq!((rcode, ancount), (0, 0), "NODATA");
3079        assert_eq!(nscount(&resp), 0, "no zone contains a single-label name");
3080    }
3081}