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