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