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