Skip to main content

ts_runtime/
magic_dns.rs

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