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