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