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 latest control-derived packet filter, shared with the peerAPI server task for the DoH
1343 /// source gate ([`crate::peerapi_doh::dns_source_allowed`]). Kept out of [`DnsView`] because it
1344 /// is not DNS data and updates on its own cadence (a netmap can carry a new filter without a new
1345 /// DNS config, and the other way round).
1346 filter_tx: watch::Sender<Option<crate::packetfilter::PacketFilterState>>,
1347 /// The runtime [`Env`], retained so each view rebuild (the `StateUpdate` / `PeerState` handlers)
1348 /// can re-read the live [`Env::accept_dns`] cell. Unlike `enable_ipv6` (snapshotted once at
1349 /// spawn), `accept_dns` is runtime-settable via `Device::set_accept_dns`, so it must be read at
1350 /// rebuild time — not captured once — for a toggle to reach the served view.
1351 env: Env,
1352 /// The overlay channel, retained so the [`Query`] handler can run a query through the same
1353 /// forward path the serve loop uses ([`forward_query`] / [`forward_doh`], both binding
1354 /// `0.0.0.0:0` on this channel — never a host socket).
1355 channel: Channel,
1356}
1357
1358/// A programmatic DNS query routed through the live MagicDNS responder (the `100.100.100.100` path),
1359/// for [`Device::query_dns`](crate::Device::query_dns). The handler synthesizes a query packet and
1360/// drives it through the exact same [`decide`]/forward logic as an on-the-wire query, so the result
1361/// (and its anti-leak posture) matches what a tailnet client would observe.
1362pub struct Query {
1363 /// The canonical name to resolve (e.g. `example.com`, no trailing dot).
1364 pub name: String,
1365 /// The DNS query type (`1`=A, `28`=AAAA, `12`=PTR, or any other RFC 1035 TYPE).
1366 pub qtype: u16,
1367}
1368
1369/// The outcome of a `Query`: the raw DNS response bytes, the RCODE, and which upstream resolvers
1370/// (if any) were consulted. The response is returned as raw bytes (matching Go `LocalClient.QueryDNS`)
1371/// rather than parsed records — this fork's wire codec has no answer-record decoder.
1372///
1373/// (`Query` is the crate-internal actor message; not linked here as it is a private item — a
1374/// `pub` doc cannot intra-doc-link to it without erroring under the doc-lint gate.)
1375#[derive(Debug, Clone, kameo::Reply)]
1376pub struct DnsQueryResult {
1377 /// The raw DNS response datagram (header + question + any answer records).
1378 pub response: Vec<u8>,
1379 /// The RCODE from the response header's low 4 bits (`0`=NoError, `2`=SERVFAIL, `3`=NXDOMAIN,
1380 /// `5`=Refused, …).
1381 pub rcode: u8,
1382 /// The upstream resolver(s) the query was forwarded to. For a UDP forward this is the candidate
1383 /// list tried in order (the forwarder returns on the first that answers); for an exit-node DoH
1384 /// forward it is the single DoH endpoint. Empty for a locally-answered query (an authoritative
1385 /// tailnet name, a NODATA, or a fail-closed NXDOMAIN — nothing egressed).
1386 pub resolvers_consulted: Vec<SocketAddr>,
1387}
1388
1389impl kameo::Actor for MagicDnsActor {
1390 type Args = (Env, Channel);
1391 type Error = Error;
1392
1393 async fn on_start(
1394 (env, channel): Self::Args,
1395 slf: ActorRef<Self>,
1396 ) -> Result<Self, Self::Error> {
1397 env.subscribe::<Arc<ts_control::StateUpdate>>(&slf).await?;
1398 env.subscribe::<Arc<PeerState>>(&slf).await?;
1399 env.subscribe::<crate::route_updater::ActiveExitNode>(&slf)
1400 .await?;
1401 // The live packet filter, for the peerAPI DoH source gate (`peerapi_doh::dns_source_allowed`,
1402 // Go `isPeerAPIDNSAllowed`). Subscribed here rather than threaded from `Runtime::new` because
1403 // this actor already owns the peerAPI server task and the view it serves from.
1404 env.subscribe::<crate::packetfilter::PacketFilterState>(&slf)
1405 .await?;
1406
1407 // Seed the view with the runtime's IPv6 gate (default off) and the current accept-dns value.
1408 // Subsequent control/peer updates clone-and-modify this view: `enable_ipv6` (set once here)
1409 // is preserved, while `accept_dns` is re-read live from `Env` on every rebuild (it is
1410 // runtime-settable). The seed value is moot — no query is served before the first
1411 // StateUpdate — but seeding it keeps the pre-update view internally consistent.
1412 let (view_tx, view_rx) = watch::channel(Arc::new(DnsView {
1413 enable_ipv6: env.enable_ipv6,
1414 accept_dns: env.accept_dns(),
1415 ..DnsView::default()
1416 }));
1417
1418 // The peerAPI DoH source gate reads the *current* filter when a query arrives; the bus has
1419 // no replay, so the latest published filter rides a `watch` cell the server task holds a
1420 // receiver on. `None` until control's first compiled filter — a refusal, not an allow.
1421 let (filter_tx, filter_rx) = watch::channel(None);
1422
1423 let mut joinset = JoinSet::new();
1424
1425 // Bind the MagicDNS socket. If the bind fails we still start (fail closed: the actor just
1426 // never answers anything) so a transient bind error doesn't take down the runtime.
1427 let addr = SocketAddr::from((MAGIC_DNS_IP, MAGIC_DNS_PORT));
1428 match channel.udp_bind(addr).await {
1429 Ok(socket) => {
1430 tracing::debug!(%addr, "magic dns responder bound");
1431 joinset.spawn(serve(socket, view_rx.clone(), channel.clone()));
1432 }
1433 Err(e) => {
1434 tracing::error!(error = %e, %addr, "magic dns udp bind failed; responder inert");
1435 }
1436 }
1437
1438 // When this node advertises a peerAPI port, run the single peerAPI server on the same shared
1439 // view. It routes `/dns-query` to the exit-node DoH handler (recursive resolution gated by
1440 // `forward_exit_egress`, see `peerapi_doh`) and `/v0/put/<name>` to the Taildrop receive
1441 // handler when a store is configured (access-gated, fail-closed, see `peerapi`).
1442 if let Some(port) = env.peerapi_port {
1443 let channel = channel.clone();
1444 let view_rx = view_rx.clone();
1445 let filter_rx = filter_rx.clone();
1446 let forward_exit_egress = env.forward_exit_egress;
1447 let taildrop = env.taildrop_store.clone();
1448 let funnel_ingress = env.funnel_ingress.clone();
1449 joinset.spawn(crate::peerapi::serve(
1450 channel,
1451 port,
1452 view_rx,
1453 filter_rx,
1454 forward_exit_egress,
1455 taildrop,
1456 funnel_ingress,
1457 ));
1458 }
1459
1460 Ok(Self {
1461 _joinset: joinset,
1462 view_tx,
1463 filter_tx,
1464 env,
1465 channel,
1466 })
1467 }
1468}
1469
1470/// A bare SERVFAIL response header for a [`Query`] whose name could not be encoded into a
1471/// well-formed query (a non-ASCII label or an over-255-byte name). A 12-byte header with QR=1 (this
1472/// is a response) and RCODE=2 (server failure); no question or answer section (we never produced a
1473/// parseable question). Lets `query_dns` return a definite, honest RCODE instead of an empty buffer
1474/// that would read back as a fabricated NoError.
1475fn servfail_response() -> Vec<u8> {
1476 let mut resp = vec![0u8; 12];
1477 // Flags: QR=1 (byte 2, 0x80) + RCODE=2 (low nibble of byte 3). All other bits clear.
1478 resp[2] = 0x80;
1479 resp[3] = 0x02;
1480 resp
1481}
1482
1483impl Message<Query> for MagicDnsActor {
1484 type Reply = DnsQueryResult;
1485
1486 async fn handle(&mut self, query: Query, _ctx: &mut Context<Self, Self::Reply>) -> Self::Reply {
1487 // Synthesize a query packet and drive it through the SAME decide/forward path the serve loop
1488 // uses, against the freshest view — so the result and its anti-leak posture exactly match an
1489 // on-the-wire query. The id is fixed (0): a programmatic query has no concurrent-demux need,
1490 // and `response_matches_query` validates the echoed id against this same buffer.
1491 //
1492 // Normalize the name into labels: strip a single trailing dot (an FQDN's root marker — Go's
1493 // `dnsname.ToFQDN` does the same) and drop empty labels. An empty label would otherwise encode
1494 // as a lone `0x00`, identical to the QNAME root terminator, truncating the wire query and
1495 // corrupting the QTYPE/QCLASS that follow.
1496 let trimmed = query.name.strip_suffix('.').unwrap_or(&query.name);
1497 let labels: Vec<String> = trimmed
1498 .split('.')
1499 .filter(|label| !label.is_empty())
1500 .map(str::to_owned)
1501 .collect();
1502 let qtype = match query.qtype {
1503 1 => ts_dns_wire::QType::A,
1504 28 => ts_dns_wire::QType::Aaaa,
1505 12 => ts_dns_wire::QType::Ptr,
1506 other => ts_dns_wire::QType::Other(other),
1507 };
1508 // Class IN (1) — the only class the responder serves authoritatively (a non-IN class still
1509 // forwards via `forward_or_nodata`, matching the on-the-wire path).
1510 let buf = ts_dns_wire::encode_query(0, &ts_dns_wire::Name(labels), &qtype, 1);
1511
1512 let view = self.view_tx.borrow().clone();
1513
1514 let (response, resolvers_consulted) = match decide(&view, &buf) {
1515 // `decide` returns `None` only when `decode_query` rejects the buffer we just built. With
1516 // the name normalized above that can still happen for a name `encode_query` accepts but
1517 // `decode_query` rejects — a non-ASCII/IDN label (the caller must pass punycode) or a name
1518 // whose wire form exceeds 255 bytes. Surface a SERVFAIL (RCODE 2: "could not process")
1519 // rather than an empty buffer that would read back as a fabricated NoError. The serve loop
1520 // silently drops here (the on-wire client times out); a programmatic caller gets a
1521 // definite, honest error instead.
1522 None => (servfail_response(), Vec::new()),
1523 Some(Decision::Reply(resp)) => (
1524 check_response_size_and_set_tc(&buf, resp, ClientTransport::Udp),
1525 Vec::new(),
1526 ),
1527 Some(Decision::Forward {
1528 upstreams,
1529 query,
1530 servfail,
1531 recursive,
1532 }) => {
1533 let plan = forward_plan(&view, upstreams, recursive);
1534 match plan {
1535 RecursivePlan::Udp(upstreams) => {
1536 let resp = forward_query(
1537 &self.channel,
1538 &upstreams,
1539 &query,
1540 servfail,
1541 ClientTransport::Udp,
1542 )
1543 .await;
1544 (resp, upstreams)
1545 }
1546 RecursivePlan::Doh(doh_addr) => {
1547 let resp = crate::peerapi_doh::forward_doh(
1548 &self.channel,
1549 doh_addr,
1550 &query,
1551 servfail,
1552 ClientTransport::Udp,
1553 )
1554 .await;
1555 // The query egressed via the exit node's DoH endpoint, not a local UDP
1556 // upstream — report the DoH address as the resolver consulted.
1557 (resp, vec![doh_addr])
1558 }
1559 }
1560 }
1561 };
1562
1563 // RCODE is the low 4 bits of the second flags byte (header byte 3).
1564 let rcode = response_rcode(&response).unwrap_or(0);
1565
1566 DnsQueryResult {
1567 response,
1568 rcode,
1569 resolvers_consulted,
1570 }
1571 }
1572}
1573
1574impl Message<Arc<ts_control::StateUpdate>> for MagicDnsActor {
1575 type Reply = ();
1576
1577 async fn handle(
1578 &mut self,
1579 update: Arc<ts_control::StateUpdate>,
1580 _ctx: &mut Context<Self, Self::Reply>,
1581 ) {
1582 // Re-read the live accept-dns cell on every rebuild (it is runtime-settable via
1583 // `Device::set_accept_dns`); `enable_ipv6` is preserved from the seed (set once at spawn).
1584 let accept_dns = self.env.accept_dns();
1585 self.view_tx.send_modify(|view| {
1586 let mut next = (**view).clone();
1587 next.cfg = update.dns_config.clone().unwrap_or_default();
1588 next.self_node = update.node.clone();
1589 next.accept_dns = accept_dns;
1590 *view = Arc::new(next);
1591 });
1592 }
1593}
1594
1595impl Message<Arc<PeerState>> for MagicDnsActor {
1596 type Reply = ();
1597
1598 async fn handle(&mut self, state: Arc<PeerState>, _ctx: &mut Context<Self, Self::Reply>) {
1599 // Re-read the live accept-dns cell on every rebuild: `Device::set_accept_dns` triggers a
1600 // `RepublishState` that lands here, so this is the path that re-applies the gate after a
1601 // runtime toggle (covers the netstack responder AND the peerAPI DoH server sharing the view).
1602 let accept_dns = self.env.accept_dns();
1603 self.view_tx.send_modify(|view| {
1604 let mut next = (**view).clone();
1605 next.peers = Some(state.peers.clone());
1606 next.accept_dns = accept_dns;
1607 *view = Arc::new(next);
1608 });
1609 }
1610}
1611
1612impl Message<crate::route_updater::ActiveExitNode> for MagicDnsActor {
1613 type Reply = ();
1614
1615 async fn handle(
1616 &mut self,
1617 active: crate::route_updater::ActiveExitNode,
1618 _ctx: &mut Context<Self, Self::Reply>,
1619 ) {
1620 // Cache the active exit node's DoH endpoint so the serve loop delegates recursive queries
1621 // to it. `None` (no exit node, or one that can't proxy DNS) keeps recursion local. Resolving
1622 // the address here — once, from the route updater's authoritative selection — means the
1623 // serve loop never re-resolves the selector.
1624 let exit_doh = active.node.as_ref().and_then(|n| n.peerapi_doh_addr());
1625 self.view_tx.send_modify(|view| {
1626 let mut next = (**view).clone();
1627 next.exit_doh = exit_doh;
1628 *view = Arc::new(next);
1629 });
1630 }
1631}
1632
1633impl Message<crate::packetfilter::PacketFilterState> for MagicDnsActor {
1634 type Reply = ();
1635
1636 async fn handle(
1637 &mut self,
1638 filter: crate::packetfilter::PacketFilterState,
1639 _ctx: &mut Context<Self, Self::Reply>,
1640 ) {
1641 // Last write wins; the peerAPI DoH source gate reads whatever is current when a peer's query
1642 // lands, so a policy change reaches it on the next query with no per-connection bookkeeping.
1643 self.filter_tx.send_replace(Some(filter));
1644 }
1645}
1646
1647#[cfg(test)]
1648mod tests {
1649 use ts_control::{StableNodeId, TailnetAddress};
1650
1651 use super::*;
1652
1653 /// Test wrapper: run [`decide`] and extract the reply bytes. These tests configure no
1654 /// upstream resolvers, so an unresolved name fails closed to a `Reply` (NXDOMAIN), never a
1655 /// `Forward`; a `Forward` here is a bug and panics.
1656 fn answer(view: &DnsView, buf: &[u8]) -> Option<Vec<u8>> {
1657 match decide(view, buf)? {
1658 Decision::Reply(resp) => Some(resp),
1659 Decision::Forward { .. } => panic!("unexpected forward in authoritative-only test"),
1660 }
1661 }
1662
1663 /// Build a `Node` named `host.user.ts.net` with a known v4/v6 tailnet address.
1664 fn test_node() -> Node {
1665 Node {
1666 id: 1,
1667 stable_id: StableNodeId("n1".to_string()),
1668 hostname: "host".to_string(),
1669 user_id: 0,
1670 tailnet: Some("user.ts.net".to_string()),
1671 tags: vec![],
1672 addresses: vec![
1673 "100.64.0.1/32".parse().unwrap(),
1674 "fd7a::1/128".parse().unwrap(),
1675 ],
1676 tailnet_address: TailnetAddress {
1677 ipv4: "100.64.0.1/32".parse().unwrap(),
1678 ipv6: "fd7a::1/128".parse().unwrap(),
1679 },
1680 node_key: [0u8; 32].into(),
1681 node_key_expiry: None,
1682 expired: false,
1683 online: None,
1684 last_seen: None,
1685 key_signature: vec![],
1686 machine_key: None,
1687 disco_key: None,
1688 accepted_routes: vec![],
1689 underlay_addresses: vec![],
1690 derp_region: None,
1691 cap: Default::default(),
1692 cap_map: Default::default(),
1693 peerapi_port: None,
1694 peerapi_dns_proxy: false,
1695 is_wireguard_only: false,
1696 exit_node_dns_resolvers: vec![],
1697 peer_relay: false,
1698 ssh_host_keys: vec![],
1699 service_vips: Default::default(),
1700 unsigned_peer_api_only: false,
1701 }
1702 }
1703
1704 /// A view with MagicDNS on and a single peer in the db.
1705 fn view_with_peer() -> DnsView {
1706 let mut db = PeerDb::default();
1707 db.upsert(&test_node());
1708
1709 DnsView {
1710 cfg: DnsConfig {
1711 magic_dns: true,
1712 search_domains: vec!["user.ts.net".to_string()],
1713 ..Default::default()
1714 },
1715 peers: Some(Arc::new(db)),
1716 self_node: None,
1717 exit_doh: None,
1718 enable_ipv6: false,
1719 accept_dns: true,
1720 }
1721 }
1722
1723 /// Build a raw DNS query buffer for `labels` with the given id, qtype, qclass.
1724 fn build_query(id: u16, labels: &[&str], qtype: u16, qclass: u16) -> Vec<u8> {
1725 let mut buf: Vec<u8> = Vec::new();
1726 buf.extend_from_slice(&id.to_be_bytes());
1727 buf.extend_from_slice(&0u16.to_be_bytes()); // flags: QR=0 (query)
1728 buf.extend_from_slice(&1u16.to_be_bytes()); // QDCOUNT
1729 buf.extend_from_slice(&0u16.to_be_bytes()); // ANCOUNT
1730 buf.extend_from_slice(&0u16.to_be_bytes()); // NSCOUNT
1731 buf.extend_from_slice(&0u16.to_be_bytes()); // ARCOUNT
1732 for label in labels {
1733 buf.push(label.len() as u8);
1734 buf.extend_from_slice(label.as_bytes());
1735 }
1736 buf.push(0); // root label
1737 buf.extend_from_slice(&qtype.to_be_bytes());
1738 buf.extend_from_slice(&qclass.to_be_bytes());
1739 buf
1740 }
1741
1742 /// `build_query` plus an EDNS(0) OPT record in the additional section advertising `udp_size` as
1743 /// the requestor's UDP payload size (RFC 6891: root NAME, TYPE 41, CLASS = the size), in the
1744 /// only shape Go's `findOPTRecord` accepts: last record in the message, version 0, `RDLEN` 0.
1745 fn build_edns_query(
1746 id: u16,
1747 labels: &[&str],
1748 qtype: u16,
1749 qclass: u16,
1750 udp_size: u16,
1751 ) -> Vec<u8> {
1752 let mut buf = build_query(id, labels, qtype, qclass);
1753 buf[11] = 1; // ARCOUNT = 1
1754 buf.push(0); // NAME: root
1755 buf.extend_from_slice(&41u16.to_be_bytes()); // TYPE: OPT
1756 buf.extend_from_slice(&udp_size.to_be_bytes()); // CLASS: requestor's UDP payload size
1757 buf.extend_from_slice(&0u32.to_be_bytes()); // TTL: extended rcode + flags
1758 buf.extend_from_slice(&0u16.to_be_bytes()); // RDLENGTH: no options
1759 buf
1760 }
1761
1762 /// Like [`build_edns_query`] but with one EDNS option in the OPT record's RDATA, so `RDLEN` is
1763 /// non-zero — the shape a stub resolver sending a DNS cookie (option code 10) produces.
1764 fn build_edns_query_with_option(
1765 id: u16,
1766 labels: &[&str],
1767 qtype: u16,
1768 qclass: u16,
1769 udp_size: u16,
1770 option_code: u16,
1771 option_data: &[u8],
1772 ) -> Vec<u8> {
1773 let mut buf = build_edns_query(id, labels, qtype, qclass, udp_size);
1774 let rdata_len = 4 + option_data.len();
1775 let rdlength_at = buf.len() - 2;
1776 buf[rdlength_at..].copy_from_slice(&(rdata_len as u16).to_be_bytes());
1777 buf.extend_from_slice(&option_code.to_be_bytes());
1778 buf.extend_from_slice(&(option_data.len() as u16).to_be_bytes());
1779 buf.extend_from_slice(option_data);
1780 buf
1781 }
1782
1783 /// Parse a response header: returns `(id, rcode, ancount)`.
1784 fn parse_header(resp: &[u8]) -> (u16, u8, u16) {
1785 let id = u16::from_be_bytes([resp[0], resp[1]]);
1786 let flags = u16::from_be_bytes([resp[2], resp[3]]);
1787 let ancount = u16::from_be_bytes([resp[6], resp[7]]);
1788 (id, (flags & 0x000F) as u8, ancount)
1789 }
1790
1791 #[test]
1792 fn a_query_for_known_peer_answers_v4() {
1793 let view = view_with_peer();
1794 let buf = build_query(0x1234, &["host", "user", "ts", "net"], 1, 1);
1795
1796 let resp = answer(&view, &buf).expect("answers");
1797 let (id, rcode, ancount) = parse_header(&resp);
1798 assert_eq!(id, 0x1234);
1799 assert_eq!(rcode, 0, "NoError");
1800 assert_eq!(ancount, 1);
1801
1802 // The trailing RDATA of the single A record is the peer's tailnet v4 octets.
1803 let tail = &resp[resp.len() - 4..];
1804 assert_eq!(tail, &[100, 64, 0, 1]);
1805 }
1806
1807 #[test]
1808 fn aaaa_query_for_known_peer_is_nodata_when_ipv6_off() {
1809 // Gate OFF (default): an AAAA query for a known overlay peer must return NoError with an
1810 // empty answer (NODATA) — NOT the overlay v6 address, which the IPv4-only client can't
1811 // route. This is the anti-fingerprint / no-dead-connections posture.
1812 let view = view_with_peer();
1813 assert!(!view.enable_ipv6, "default gate is off");
1814 let buf = build_query(0x5, &["host", "user", "ts", "net"], 28, 1);
1815
1816 let resp = answer(&view, &buf).expect("answers");
1817 let (_, rcode, ancount) = parse_header(&resp);
1818 assert_eq!(rcode, 0, "NoError (NODATA)");
1819 assert_eq!(ancount, 0, "empty answer: no AAAA handed out with IPv6 off");
1820 }
1821
1822 #[test]
1823 fn a_query_still_resolves_when_ipv6_off() {
1824 // Gate OFF must not touch the A (v4) path: the v4 answer is byte-for-byte unchanged.
1825 let view = view_with_peer();
1826 let buf = build_query(0x6, &["host", "user", "ts", "net"], 1, 1);
1827
1828 let resp = answer(&view, &buf).expect("answers");
1829 let (_, rcode, ancount) = parse_header(&resp);
1830 assert_eq!(rcode, 0, "NoError");
1831 assert_eq!(ancount, 1);
1832 let tail = &resp[resp.len() - 4..];
1833 assert_eq!(tail, &[100, 64, 0, 1]);
1834 }
1835
1836 #[test]
1837 fn aaaa_query_for_known_peer_answers_v6_when_ipv6_on() {
1838 // Gate ON: historical behavior — answer AAAA from the overlay v6 address.
1839 let mut view = view_with_peer();
1840 view.enable_ipv6 = true;
1841 let buf = build_query(0x5, &["host", "user", "ts", "net"], 28, 1);
1842
1843 let resp = answer(&view, &buf).expect("answers");
1844 let (_, rcode, ancount) = parse_header(&resp);
1845 assert_eq!(rcode, 0, "NoError");
1846 assert_eq!(ancount, 1);
1847
1848 let expected = "fd7a::1".parse::<std::net::Ipv6Addr>().unwrap().octets();
1849 let tail = &resp[resp.len() - 16..];
1850 assert_eq!(tail, expected);
1851 }
1852
1853 #[test]
1854 fn aaaa_for_unknown_tailnet_name_is_nxdomain_not_forwarded_with_ipv6_off() {
1855 // Anti-leak, unchanged by the gate: an AAAA for a name under the tailnet suffix that has no
1856 // overlay match still fails closed to NXDOMAIN — never forwarded to a recursive upstream,
1857 // even with resolvers configured. (Gate OFF only changes the *positive* overlay match into
1858 // NODATA; a non-match still routes through `forward_or_nxdomain`.)
1859 let mut db = PeerDb::default();
1860 db.upsert(&test_node());
1861 let view = DnsView {
1862 cfg: DnsConfig {
1863 magic_dns: true,
1864 search_domains: vec!["user.ts.net".to_string()],
1865 fallback_resolvers: vec![DnsResolver {
1866 transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
1867 use_with_exit_node: false,
1868 }],
1869 ..Default::default()
1870 },
1871 peers: Some(Arc::new(db)),
1872 self_node: None,
1873 exit_doh: None,
1874 enable_ipv6: false,
1875 accept_dns: true,
1876 };
1877 let buf = build_query(0x5A, &["ghost", "user", "ts", "net"], 28, 1);
1878
1879 match decide(&view, &buf).expect("decides") {
1880 Decision::Reply(resp) => {
1881 let (_, rcode, _) = parse_header(&resp);
1882 assert_eq!(rcode, 3, "NxDomain: tailnet AAAA not leaked upstream");
1883 }
1884 Decision::Forward { .. } => panic!("tailnet AAAA must never be forwarded"),
1885 }
1886 }
1887
1888 #[test]
1889 fn bare_hostname_resolves() {
1890 // The name index also stores the bare hostname.
1891 let view = view_with_peer();
1892 let buf = build_query(0x7, &["host"], 1, 1);
1893
1894 let resp = answer(&view, &buf).expect("answers");
1895 let (_, rcode, ancount) = parse_header(&resp);
1896 assert_eq!(rcode, 0);
1897 assert_eq!(ancount, 1);
1898 }
1899
1900 #[test]
1901 fn unknown_off_tailnet_name_with_no_upstream_is_servfail() {
1902 // An off-tailnet name with no resolver configured cannot be forwarded. Go answers SERVFAIL
1903 // (a soft "couldn't resolve"), not NXDOMAIN — asserting non-existence of a real name we
1904 // simply have no upstream for would poison a downstream stub's negative cache. (A *tailnet*
1905 // name with no overlay match stays NXDOMAIN — see `tailnet_name_is_never_forwarded` — and a
1906 // negative split-DNS route stays NXDOMAIN — see `negative_route_is_nxdomain_not_forwarded`.)
1907 let view = view_with_peer();
1908 let buf = build_query(0x9, &["nope", "example", "com"], 1, 1);
1909
1910 let resp = answer(&view, &buf).expect("answers");
1911 let (_, rcode, ancount) = parse_header(&resp);
1912 assert_eq!(
1913 rcode, 2,
1914 "ServFail: off-tailnet name, nothing to forward to"
1915 );
1916 assert_eq!(ancount, 0);
1917 }
1918
1919 #[test]
1920 fn magic_dns_off_is_refused() {
1921 // Fail closed: with MagicDNS disabled, even a known name is refused.
1922 let mut view = view_with_peer();
1923 view.cfg.magic_dns = false;
1924 let buf = build_query(0xAB, &["host", "user", "ts", "net"], 1, 1);
1925
1926 let resp = answer(&view, &buf).expect("answers");
1927 let (_, rcode, ancount) = parse_header(&resp);
1928 assert_eq!(rcode, 5, "Refused");
1929 assert_eq!(ancount, 0);
1930 }
1931
1932 #[test]
1933 fn accept_dns_false_refuses_otherwise_answerable_query() {
1934 // The accept-dns gate (Go `CorpDNS`): with `accept_dns == false` the node ignores the
1935 // tailnet DNS config, so even a known peer name that would normally answer authoritatively is
1936 // REFUSED (the responder serves nothing) — mirroring Go applying an empty `dns.Config`.
1937 let mut view = view_with_peer();
1938 assert!(view.cfg.magic_dns, "MagicDNS itself is on");
1939 view.accept_dns = false;
1940 let buf = build_query(0xDD, &["host", "user", "ts", "net"], 1, 1);
1941
1942 let resp = answer(&view, &buf).expect("answers");
1943 let (_, rcode, ancount) = parse_header(&resp);
1944 assert_eq!(rcode, 5, "Refused: accept_dns off ⇒ serve nothing");
1945 assert_eq!(ancount, 0);
1946
1947 // Flip accept_dns back ON (the config was never destroyed, only gated): the same query now
1948 // answers authoritatively — proving the OFF→ON restore is automatic.
1949 view.accept_dns = true;
1950 let resp = answer(&view, &buf).expect("answers");
1951 let (_, rcode, ancount) = parse_header(&resp);
1952 assert_eq!(rcode, 0, "NoError: accept_dns on ⇒ the known peer answers");
1953 assert_eq!(ancount, 1);
1954 let tail = &resp[resp.len() - 4..];
1955 assert_eq!(tail, &[100, 64, 0, 1], "the peer's tailnet v4 is served");
1956 }
1957
1958 #[test]
1959 fn default_view_serves_nothing() {
1960 // The default (no dns_config seen) has magic_dns == false: fail closed.
1961 let view = DnsView::default();
1962 let buf = build_query(0x1, &["host", "user", "ts", "net"], 1, 1);
1963
1964 let resp = answer(&view, &buf).expect("answers");
1965 let (_, rcode, _) = parse_header(&resp);
1966 assert_eq!(rcode, 5, "Refused");
1967 }
1968
1969 #[test]
1970 fn unsupported_qtype_on_tailnet_name_is_nodata_not_refused() {
1971 // TXT (type 16) for a tailnet-authoritative name: the name exists but we hold no TXT, so —
1972 // like Go — return NODATA (empty NOERROR), NOT REFUSED (which would make a stub abandon the
1973 // resolver) and NOT NXDOMAIN (the name exists). The name is never forwarded (anti-leak).
1974 let view = view_with_peer();
1975 let buf = build_query(0x1, &["host", "user", "ts", "net"], 16, 1);
1976
1977 let resp = answer(&view, &buf).expect("answers");
1978 let (_, rcode, ancount) = parse_header(&resp);
1979 assert_eq!(rcode, 0, "NoError (NODATA), not Refused");
1980 assert_eq!(ancount, 0, "no answer records (NODATA)");
1981 }
1982
1983 #[test]
1984 fn unsupported_qtype_off_tailnet_forwards_or_servfails() {
1985 // A non-A/AAAA/PTR qtype for an OFF-tailnet name must be forwardable like A/AAAA — never
1986 // REFUSED. With no upstream configured in this view it soft-fails to SERVFAIL (the same
1987 // disposition an off-tailnet A query gets here), proving the qtype no longer short-circuits
1988 // to REFUSED. HTTPS/SVCB is type 65 (the browser HTTP/3 + ECH case the old REFUSED broke).
1989 let view = view_with_peer();
1990 let buf = build_query(0x1, &["example", "com"], 65, 1);
1991
1992 let resp = answer(&view, &buf).expect("answers");
1993 let (_, rcode, _) = parse_header(&resp);
1994 assert_eq!(
1995 rcode, 2,
1996 "off-tailnet, no upstream -> SERVFAIL (forwardable, not Refused)"
1997 );
1998 }
1999
2000 #[test]
2001 fn unimplemented_qtype_on_tailnet_name_is_notimp() {
2002 // NS (2), SOA (6), HINFO (13), AXFR (252) for a tailnet-authoritative name must answer NOTIMP
2003 // (rcode 4), matching Go `resolveLocal`'s `case dns.TypeNS, dns.TypeSOA, dns.TypeAXFR,
2004 // dns.TypeHINFO: return RCodeNotImplemented`. Returning NODATA (rcode 0) here was a clean
2005 // fingerprint (a `dig SOA user.ts.net` answer differs from real tailscaled). The name is
2006 // still never forwarded (anti-leak).
2007 let view = view_with_peer();
2008 for qtype in [2u16, 6, 13, 252] {
2009 let buf = build_query(0x1, &["host", "user", "ts", "net"], qtype, 1);
2010 let resp = answer(&view, &buf).expect("answers");
2011 let (_, rcode, ancount) = parse_header(&resp);
2012 assert_eq!(rcode, 4, "qtype {qtype} on a tailnet name must be NOTIMP");
2013 assert_eq!(ancount, 0, "NOTIMP carries no answer records");
2014 }
2015 }
2016
2017 #[test]
2018 fn unimplemented_qtype_off_tailnet_still_forwards_not_notimp() {
2019 // The NOTIMP disposition is ONLY for a name we are authoritative for. An NS query for an
2020 // off-tailnet name must still forward (here: SERVFAIL, no upstream) — NOT NOTIMP — exactly
2021 // like the off-tailnet HTTPS/SVCB case above. Guards the NOTIMP change against over-reach.
2022 let view = view_with_peer();
2023 let buf = build_query(0x1, &["example", "com"], 2, 1); // NS, off-tailnet
2024 let resp = answer(&view, &buf).expect("answers");
2025 let (_, rcode, _) = parse_header(&resp);
2026 assert_eq!(
2027 rcode, 2,
2028 "off-tailnet NS -> SERVFAIL (forwardable), not NOTIMP"
2029 );
2030 }
2031
2032 #[test]
2033 fn malformed_query_is_dropped() {
2034 // A response (QR bit set) is not a query; we drop it (no answer).
2035 let mut buf = build_query(0x1, &["host"], 1, 1);
2036 buf[2] = 0x80; // set QR bit
2037 assert!(answer(&view_with_peer(), &buf).is_none());
2038 }
2039
2040 #[test]
2041 fn ptr_for_known_ip_answers_fqdn() {
2042 let view = view_with_peer();
2043 // Reverse name for 100.64.0.1 => 1.0.64.100.in-addr.arpa
2044 let buf = build_query(0x33, &["1", "0", "64", "100", "in-addr", "arpa"], 12, 1);
2045
2046 let resp = answer(&view, &buf).expect("answers");
2047 let (_, rcode, ancount) = parse_header(&resp);
2048 assert_eq!(rcode, 0, "NoError");
2049 assert_eq!(ancount, 1);
2050
2051 // The PTR rdata encodes the peer's fqdn "host.user.ts.net" as length-prefixed labels.
2052 let expected = {
2053 let mut out = Vec::new();
2054 for label in ["host", "user", "ts", "net"] {
2055 out.push(label.len() as u8);
2056 out.extend_from_slice(label.as_bytes());
2057 }
2058 out.push(0);
2059 out
2060 };
2061 let tail = &resp[resp.len() - expected.len()..];
2062 assert_eq!(tail, expected.as_slice());
2063 }
2064
2065 #[test]
2066 fn ptr_for_unknown_public_ip_off_tailnet_is_servfail() {
2067 let view = view_with_peer();
2068 // 9.9.9.9 is a public IP, not a known tailnet IP and not in the CGNAT reverse zone — so its
2069 // reverse query is an ordinary off-tailnet name. With no upstream to forward it to, that is
2070 // SERVFAIL (soft), not NXDOMAIN. (A CGNAT/ip6.arpa reverse for an unmatched tailnet IP still
2071 // fails closed to NXDOMAIN as an anti-leak guard — see `ptr_for_unknown_tailnet_ip_*`.)
2072 let buf = build_query(0x34, &["9", "9", "9", "9", "in-addr", "arpa"], 12, 1);
2073
2074 let resp = answer(&view, &buf).expect("answers");
2075 let (_, rcode, _) = parse_header(&resp);
2076 assert_eq!(
2077 rcode, 2,
2078 "ServFail: off-tailnet public-IP reverse, no upstream"
2079 );
2080 }
2081
2082 #[test]
2083 fn ptr_for_unknown_tailnet_ip_is_nxdomain_not_forwarded() {
2084 // A view WITH an upstream resolver: an off-tailnet reverse query would forward, but a
2085 // reverse query for an unmatched IP in the CGNAT range (100.64.0.0/10) must fail closed to
2086 // NXDOMAIN — the probed tailnet IP must never leak upstream.
2087 let mut db = PeerDb::default();
2088 db.upsert(&test_node());
2089 let view = DnsView {
2090 cfg: DnsConfig {
2091 magic_dns: true,
2092 search_domains: vec!["user.ts.net".to_string()],
2093 fallback_resolvers: vec![DnsResolver {
2094 transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
2095 use_with_exit_node: false,
2096 }],
2097 ..Default::default()
2098 },
2099 peers: Some(Arc::new(db)),
2100 self_node: None,
2101 exit_doh: None,
2102 enable_ipv6: false,
2103 accept_dns: true,
2104 };
2105
2106 // 100.64.0.9 is in CGNAT range but owned by no peer => NXDOMAIN, never a Forward.
2107 let buf = build_query(0x35, &["9", "0", "64", "100", "in-addr", "arpa"], 12, 1);
2108 match decide(&view, &buf).expect("decides") {
2109 Decision::Reply(resp) => {
2110 let (_, rcode, _) = parse_header(&resp);
2111 assert_eq!(rcode, 3, "NxDomain");
2112 }
2113 Decision::Forward { .. } => {
2114 panic!("tailnet CGNAT PTR must never be forwarded upstream")
2115 }
2116 }
2117 }
2118
2119 /// Anti-leak regression for the exotic-qtype forward path: a NON-PTR query (TXT, type 16) for a
2120 /// tailnet CGNAT reverse name, with an upstream configured, must STILL fail closed to NXDOMAIN —
2121 /// never forward. The PTR arm guards this, but the `QType::Other` path routes through
2122 /// `forward_or_nodata`, which must re-apply the reverse-zone guard or the tailnet IP leaks.
2123 #[test]
2124 fn exotic_qtype_for_tailnet_cgnat_reverse_is_nxdomain_not_forwarded() {
2125 let mut db = PeerDb::default();
2126 db.upsert(&test_node());
2127 let view = DnsView {
2128 cfg: DnsConfig {
2129 magic_dns: true,
2130 search_domains: vec!["user.ts.net".to_string()],
2131 fallback_resolvers: vec![DnsResolver {
2132 transport: ts_control::ResolverTransport::Udp("9.9.9.9:53".parse().unwrap()),
2133 use_with_exit_node: false,
2134 }],
2135 ..Default::default()
2136 },
2137 peers: Some(Arc::new(db)),
2138 self_node: None,
2139 exit_doh: None,
2140 enable_ipv6: false,
2141 accept_dns: true,
2142 };
2143
2144 // TXT (16) for a CGNAT reverse name => NXDOMAIN, never a Forward (no tailnet-IP leak).
2145 let buf = build_query(0x36, &["9", "0", "64", "100", "in-addr", "arpa"], 16, 1);
2146 match decide(&view, &buf).expect("decides") {
2147 Decision::Reply(resp) => {
2148 let (_, rcode, _) = parse_header(&resp);
2149 assert_eq!(rcode, 3, "NxDomain");
2150 }
2151 Decision::Forward { .. } => {
2152 panic!("a non-PTR query for a tailnet CGNAT reverse name must never forward")
2153 }
2154 }
2155 }
2156
2157 /// Same anti-leak guard for an `ip6.arpa` reverse name under an exotic qtype: must NXDOMAIN, not
2158 /// forward (revealing a tailnet ULA was probed).
2159 #[test]
2160 fn exotic_qtype_for_ip6_arpa_is_nxdomain_not_forwarded() {
2161 let view = view_with_routes(
2162 std::collections::BTreeMap::new(),
2163 vec![udp("9.9.9.9:53")],
2164 vec![],
2165 );
2166 // An ip6.arpa reverse name with a TXT (16) qtype must fail closed.
2167 let buf = build_query(
2168 0x37,
2169 &[
2170 "1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0",
2171 "a", "7", "d", "f", "ip6", "arpa",
2172 ],
2173 16,
2174 1,
2175 );
2176 match decide(&view, &buf).expect("decides") {
2177 Decision::Reply(resp) => {
2178 let (_, rcode, _) = parse_header(&resp);
2179 assert_eq!(rcode, 3, "NxDomain");
2180 }
2181 Decision::Forward { .. } => panic!("an ip6.arpa exotic-qtype query must never forward"),
2182 }
2183 }
2184
2185 #[test]
2186 fn is_tailnet_cgnat_classifies_range() {
2187 assert!(is_tailnet_cgnat("100.64.0.0".parse().unwrap()));
2188 assert!(is_tailnet_cgnat("100.64.0.1".parse().unwrap()));
2189 assert!(is_tailnet_cgnat("100.127.255.255".parse().unwrap()));
2190 // Outside the /10:
2191 assert!(!is_tailnet_cgnat("100.63.255.255".parse().unwrap()));
2192 assert!(!is_tailnet_cgnat("100.128.0.0".parse().unwrap()));
2193 assert!(!is_tailnet_cgnat("9.9.9.9".parse().unwrap()));
2194 // The MagicDNS resolver IP 100.100.100.100 is itself inside the /10.
2195 assert!(is_tailnet_cgnat("100.100.100.100".parse().unwrap()));
2196 }
2197
2198 #[test]
2199 fn response_matches_query_validates_id_and_qr() {
2200 // query id 0x1234, QR=0
2201 let query = build_query(0x1234, &["a", "com"], 1, 1);
2202
2203 // A well-formed response: same id, QR=1.
2204 let mut good = query.clone();
2205 good[2] |= 0x80;
2206 assert!(response_matches_query(&query, &good));
2207
2208 // Same id but QR still 0 (not a response): rejected.
2209 assert!(!response_matches_query(&query, &query));
2210
2211 // QR=1 but a different transaction id: rejected (off-path forgery).
2212 let mut wrong_id = good.clone();
2213 wrong_id[0] ^= 0xFF;
2214 assert!(!response_matches_query(&query, &wrong_id));
2215
2216 // Too-short buffers: rejected.
2217 assert!(!response_matches_query(&query, &[0u8; 2]));
2218 assert!(!response_matches_query(&[0u8; 3], &good));
2219 }
2220
2221 #[test]
2222 fn self_node_resolves_when_no_peer_match() {
2223 // With the peer db empty but a self node set, the self node answers for its own name.
2224 let view = DnsView {
2225 cfg: DnsConfig {
2226 magic_dns: true,
2227 search_domains: vec![],
2228 ..Default::default()
2229 },
2230 peers: None,
2231 self_node: Some(test_node()),
2232 exit_doh: None,
2233 enable_ipv6: false,
2234 accept_dns: true,
2235 };
2236 let buf = build_query(0x44, &["host", "user", "ts", "net"], 1, 1);
2237
2238 let resp = answer(&view, &buf).expect("answers");
2239 let (_, rcode, ancount) = parse_header(&resp);
2240 assert_eq!(rcode, 0);
2241 assert_eq!(ancount, 1);
2242 let tail = &resp[resp.len() - 4..];
2243 assert_eq!(tail, &[100, 64, 0, 1]);
2244 }
2245
2246 #[test]
2247 fn partially_qualified_name_resolves_via_search_domain() {
2248 // "host.user" is not indexed directly, but the "user.ts.net" search domain qualifies it
2249 // to "host.user.user.ts.net"... which does NOT match. The realistic case is "host" (bare,
2250 // already indexed) and "host.user.ts.net" (fqdn). Verify a name needing suffix expansion:
2251 // with search domain "ts.net" the partially-qualified "host.user" => "host.user.ts.net".
2252 let mut view = view_with_peer();
2253 view.cfg.search_domains = vec!["ts.net".to_string()];
2254 let buf = build_query(0x55, &["host", "user"], 1, 1);
2255
2256 let resp = answer(&view, &buf).expect("answers");
2257 let (_, rcode, ancount) = parse_header(&resp);
2258 assert_eq!(rcode, 0, "NoError via search-domain expansion");
2259 assert_eq!(ancount, 1);
2260 let tail = &resp[resp.len() - 4..];
2261 assert_eq!(tail, &[100, 64, 0, 1]);
2262 }
2263
2264 #[test]
2265 fn extra_record_a_answers_when_no_peer_match() {
2266 // A control-pushed static A record answers for a non-peer name, fail-closed otherwise.
2267 let mut view = view_with_peer();
2268 view.cfg.extra_records = vec![ts_control::ExtraRecord {
2269 name: "static.user.ts.net".to_string(),
2270 addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2271 }];
2272 let buf = build_query(0x77, &["static", "user", "ts", "net"], 1, 1);
2273
2274 let resp = answer(&view, &buf).expect("answers");
2275 let (_, rcode, ancount) = parse_header(&resp);
2276 assert_eq!(rcode, 0, "NoError from extra record");
2277 assert_eq!(ancount, 1);
2278 let tail = &resp[resp.len() - 4..];
2279 assert_eq!(tail, &[100, 64, 0, 9]);
2280 }
2281
2282 #[test]
2283 fn extra_record_matches_query_case_insensitively() {
2284 // The query name is canonicalized (lowercased) at decode time, so a mixed-case query
2285 // matches a lowercase extra record.
2286 let mut view = view_with_peer();
2287 view.cfg.extra_records = vec![ts_control::ExtraRecord {
2288 name: "static.user.ts.net".to_string(),
2289 addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2290 }];
2291 let buf = build_query(0x7A, &["Static", "User", "TS", "net"], 1, 1);
2292
2293 let resp = answer(&view, &buf).expect("answers");
2294 let (_, rcode, ancount) = parse_header(&resp);
2295 assert_eq!(rcode, 0, "NoError: case-insensitive match");
2296 assert_eq!(ancount, 1);
2297 let tail = &resp[resp.len() - 4..];
2298 assert_eq!(tail, &[100, 64, 0, 9]);
2299 }
2300
2301 #[test]
2302 fn extra_record_not_expanded_by_search_domain() {
2303 // Unlike peer names, an extra record is matched as an FQDN only: a bare query that would
2304 // need search-domain expansion to reach the record name must NOT resolve.
2305 let mut view = view_with_peer();
2306 view.cfg.extra_records = vec![ts_control::ExtraRecord {
2307 name: "static.user.ts.net".to_string(),
2308 addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2309 }];
2310 // "static" would only reach "static.user.ts.net" via the "user.ts.net" search domain.
2311 let buf = build_query(0x7B, &["static"], 1, 1);
2312
2313 let resp = answer(&view, &buf).expect("answers");
2314 let (_, rcode, _) = parse_header(&resp);
2315 // Not search-expanded → treated as the bare off-tailnet name "static", which has no upstream
2316 // here, so SERVFAIL (soft). The point of the test — that the extra record is NOT reachable
2317 // via search expansion — holds regardless of the failure rcode.
2318 assert_eq!(
2319 rcode, 2,
2320 "ServFail: bare 'static' is not search-expanded to the extra record"
2321 );
2322 }
2323
2324 #[test]
2325 fn extra_record_aaaa_family_is_isolated() {
2326 // An A-only extra record must NOT answer an AAAA query for the same name (NxDomain).
2327 let mut view = view_with_peer();
2328 view.cfg.extra_records = vec![ts_control::ExtraRecord {
2329 name: "v4only.user.ts.net".to_string(),
2330 addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2331 }];
2332 let buf = build_query(0x78, &["v4only", "user", "ts", "net"], 28, 1);
2333
2334 let resp = answer(&view, &buf).expect("answers");
2335 let (_, rcode, _) = parse_header(&resp);
2336 assert_eq!(rcode, 3, "NxDomain: A record does not satisfy AAAA");
2337 }
2338
2339 #[test]
2340 fn extra_record_ignored_when_magic_dns_off() {
2341 // Fail closed: extra records are never served while MagicDNS is disabled.
2342 let mut view = view_with_peer();
2343 view.cfg.magic_dns = false;
2344 view.cfg.extra_records = vec![ts_control::ExtraRecord {
2345 name: "static.user.ts.net".to_string(),
2346 addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2347 }];
2348 let buf = build_query(0x79, &["static", "user", "ts", "net"], 1, 1);
2349
2350 let resp = answer(&view, &buf).expect("answers");
2351 let (_, rcode, _) = parse_header(&resp);
2352 assert_eq!(rcode, 5, "Refused");
2353 }
2354
2355 /// The node attribute control sets to make every subdomain of a node resolve to it (Go
2356 /// `tailcfg/nodecap`'s `NodeAttrDNSSubdomainResolve`).
2357 const DNS_SUBDOMAIN_RESOLVE: &str = "dns-subdomain-resolve";
2358
2359 /// A view holding a single peer `host.user.ts.net` that carries the `dns-subdomain-resolve`
2360 /// node attribute, so control has declared every name under it to resolve to its addresses.
2361 fn view_with_subdomain_host() -> DnsView {
2362 let mut node = test_node();
2363 node.cap_map
2364 .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
2365
2366 let mut db = PeerDb::default();
2367 db.upsert(&node);
2368
2369 let mut view = view_with_peer();
2370 view.peers = Some(Arc::new(db));
2371 view
2372 }
2373
2374 #[test]
2375 fn subdomain_of_a_subdomain_host_resolves_to_it() {
2376 // `my.host.user.ts.net` has no record of its own; its parent `host.user.ts.net` carries the
2377 // attribute, so it answers with the parent's address.
2378 let view = view_with_subdomain_host();
2379 let buf = build_query(0x90, &["my", "host", "user", "ts", "net"], 1, 1);
2380
2381 let resp = answer(&view, &buf).expect("answers");
2382 let (_, rcode, ancount) = parse_header(&resp);
2383 assert_eq!(rcode, 0, "NoError from the subdomain host");
2384 assert_eq!(ancount, 1);
2385 assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
2386 }
2387
2388 #[test]
2389 fn a_multi_label_subdomain_of_a_subdomain_host_resolves() {
2390 // The walk climbs every parent, not one level: `be.my.host` reaches `host` just as
2391 // `my.host` does. One level of parent is not what upstream implements.
2392 let view = view_with_subdomain_host();
2393 let buf = build_query(0x91, &["be", "my", "host", "user", "ts", "net"], 1, 1);
2394
2395 let resp = answer(&view, &buf).expect("answers");
2396 let (_, rcode, ancount) = parse_header(&resp);
2397 assert_eq!(rcode, 0, "NoError: the walk is not depth-limited");
2398 assert_eq!(ancount, 1);
2399 assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
2400 }
2401
2402 #[test]
2403 fn subdomain_of_a_peer_without_the_attribute_is_nxdomain() {
2404 // The attribute is what turns the walk on. Without it — the default for every node — a
2405 // subdomain of a peer name is still authoritatively absent.
2406 let view = view_with_peer();
2407 assert!(
2408 !view
2409 .node_by_name("host.user.ts.net")
2410 .expect("peer is present")
2411 .resolves_subdomains(),
2412 "the plain test peer carries no node attribute"
2413 );
2414 let buf = build_query(0x92, &["my", "host", "user", "ts", "net"], 1, 1);
2415
2416 let resp = answer(&view, &buf).expect("answers");
2417 let (_, rcode, ancount) = parse_header(&resp);
2418 assert_eq!(rcode, 3, "NxDomain: no attribute, no subdomain resolution");
2419 assert_eq!(ancount, 0);
2420 }
2421
2422 #[test]
2423 fn an_exact_match_beats_the_subdomain_host() {
2424 // The walk is the *miss* path: a name that resolves exactly — here a control-pushed extra
2425 // record — keeps its own answer, and never takes the parent's.
2426 let mut view = view_with_subdomain_host();
2427 view.cfg.extra_records = vec![ts_control::ExtraRecord {
2428 name: "my.host.user.ts.net".to_string(),
2429 addr: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 9)),
2430 }];
2431 let buf = build_query(0x93, &["my", "host", "user", "ts", "net"], 1, 1);
2432
2433 let resp = answer(&view, &buf).expect("answers");
2434 let (_, rcode, ancount) = parse_header(&resp);
2435 assert_eq!(rcode, 0, "NoError");
2436 assert_eq!(ancount, 1);
2437 assert_eq!(
2438 &resp[resp.len() - 4..],
2439 &[100, 64, 0, 9],
2440 "the exact record answers, not the subdomain host's address"
2441 );
2442 }
2443
2444 #[test]
2445 fn the_subdomain_walk_stops_at_the_tailnet_zone() {
2446 // A node whose own FQDN *is* the search domain must not make the whole zone a wildcard:
2447 // the walk stops at the zone apex rather than climbing into names we do not serve.
2448 let mut zone_node = test_node();
2449 zone_node.hostname = "user".to_string();
2450 zone_node.tailnet = Some("ts.net".to_string());
2451 zone_node
2452 .cap_map
2453 .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
2454 assert_eq!(zone_node.fqdn(false), "user.ts.net", "the zone apex itself");
2455
2456 let mut db = PeerDb::default();
2457 db.upsert(&zone_node);
2458 let mut view = view_with_peer();
2459 view.peers = Some(Arc::new(db));
2460
2461 for labels in [
2462 ["nothing", "user", "ts", "net"].as_slice(),
2463 ["deeper", "nothing", "user", "ts", "net"].as_slice(),
2464 ] {
2465 let buf = build_query(0x94, labels, 1, 1);
2466 let resp = answer(&view, &buf).expect("answers");
2467 let (_, rcode, ancount) = parse_header(&resp);
2468 assert_eq!(rcode, 3, "NxDomain: the walk stopped at {:?}", labels);
2469 assert_eq!(ancount, 0);
2470 }
2471 }
2472
2473 #[test]
2474 fn the_subdomain_walk_does_not_search_expand_a_bare_label() {
2475 // The peer-name index also holds bare hostnames, so a peer named after a public suffix must
2476 // not swallow every name under it: only a fully-qualified parent is a walk candidate. Go
2477 // cannot do this at all — its resolver does no search-list expansion.
2478 let mut suffix_node = test_node();
2479 suffix_node.hostname = "com".to_string();
2480 suffix_node
2481 .cap_map
2482 .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
2483
2484 let mut db = PeerDb::default();
2485 db.upsert(&suffix_node);
2486 let mut view = view_with_peer();
2487 view.peers = Some(Arc::new(db));
2488
2489 let buf = build_query(0x95, &["www", "example", "com"], 1, 1);
2490 let resp = answer(&view, &buf).expect("answers");
2491 let (_, rcode, ancount) = parse_header(&resp);
2492 assert_eq!(
2493 rcode, 2,
2494 "ServFail: an off-tailnet name with no upstream, NOT the peer named 'com'"
2495 );
2496 assert_eq!(ancount, 0, "no answer manufactured from a bare hostname");
2497
2498 // The qualified form of the same peer still resolves its subdomains: the bound rejects the
2499 // bare label, not the subdomain host.
2500 let buf = build_query(0x96, &["www", "com", "user", "ts", "net"], 1, 1);
2501 let resp = answer(&view, &buf).expect("answers");
2502 let (_, rcode, ancount) = parse_header(&resp);
2503 assert_eq!(rcode, 0, "NoError from com.user.ts.net");
2504 assert_eq!(ancount, 1);
2505 assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
2506 }
2507
2508 #[test]
2509 fn aaaa_for_a_subdomain_host_follows_the_ipv6_gate() {
2510 // The subdomain answer is the parent node's address, so it takes the same AAAA gate an
2511 // exact peer match does: NODATA with IPv6 off, the overlay v6 with it on.
2512 let mut view = view_with_subdomain_host();
2513 let buf = build_query(0x97, &["my", "host", "user", "ts", "net"], 28, 1);
2514
2515 let resp = answer(&view, &buf).expect("answers");
2516 let (_, rcode, ancount) = parse_header(&resp);
2517 assert_eq!(rcode, 0, "NoError (NODATA) with the gate off");
2518 assert_eq!(ancount, 0);
2519
2520 view.enable_ipv6 = true;
2521 let resp = answer(&view, &buf).expect("answers");
2522 let (_, rcode, ancount) = parse_header(&resp);
2523 assert_eq!(rcode, 0, "NoError");
2524 assert_eq!(ancount, 1);
2525 let expected = "fd7a::1".parse::<std::net::Ipv6Addr>().unwrap().octets();
2526 assert_eq!(&resp[resp.len() - 16..], expected);
2527 }
2528
2529 #[test]
2530 fn a_subdomain_of_the_self_node_resolves_when_it_has_the_attribute() {
2531 // The walk runs over the same name lookup the exact match uses, so the self node is a
2532 // subdomain host too when control sets the attribute on it.
2533 let mut self_node = test_node();
2534 self_node.hostname = "me".to_string();
2535 self_node
2536 .cap_map
2537 .insert(DNS_SUBDOMAIN_RESOLVE.to_string(), vec![]);
2538
2539 let mut view = view_with_peer();
2540 view.peers = None;
2541 view.self_node = Some(self_node);
2542
2543 let buf = build_query(0x98, &["a", "b", "me", "user", "ts", "net"], 1, 1);
2544 let resp = answer(&view, &buf).expect("answers");
2545 let (_, rcode, ancount) = parse_header(&resp);
2546 assert_eq!(rcode, 0, "NoError from the self node");
2547 assert_eq!(ancount, 1);
2548 assert_eq!(&resp[resp.len() - 4..], &[100, 64, 0, 1]);
2549 }
2550
2551 #[test]
2552 fn non_in_class_on_tailnet_name_is_nodata_not_answered_as_in() {
2553 // A CHAOS-class (3) query for a tailnet name must NOT be answered as IN (no overlay A), and
2554 // must NOT be REFUSED (Go does no class check on the local path). It's an unsupported
2555 // authoritative class -> NODATA (empty NOERROR), and never forwarded (tailnet name).
2556 let view = view_with_peer();
2557 let buf = build_query(0x66, &["host", "user", "ts", "net"], 1, 3);
2558
2559 let resp = answer(&view, &buf).expect("answers");
2560 let (_, rcode, ancount) = parse_header(&resp);
2561 assert_eq!(
2562 rcode, 0,
2563 "NoError (NODATA), not Refused and not an IN answer"
2564 );
2565 assert_eq!(
2566 ancount, 0,
2567 "must not hand out the overlay A for a non-IN class"
2568 );
2569 }
2570
2571 #[test]
2572 fn non_in_class_off_tailnet_forwards_or_servfails() {
2573 // A non-IN class for an OFF-tailnet name is forwardable (Go forwards it), never REFUSED.
2574 // No upstream here -> SERVFAIL, proving the class gate no longer short-circuits to Refused.
2575 let view = view_with_peer();
2576 let buf = build_query(0x66, &["example", "com"], 1, 3);
2577
2578 let resp = answer(&view, &buf).expect("answers");
2579 let (_, rcode, _) = parse_header(&resp);
2580 assert_eq!(
2581 rcode, 2,
2582 "off-tailnet non-IN class, no upstream -> SERVFAIL, not Refused"
2583 );
2584 }
2585
2586 /// A view with MagicDNS on, the `user.ts.net` search domain, and the given split-DNS routes
2587 /// + global resolvers.
2588 fn view_with_routes(
2589 routes: std::collections::BTreeMap<String, Vec<DnsResolver>>,
2590 resolvers: Vec<DnsResolver>,
2591 fallback: Vec<DnsResolver>,
2592 ) -> DnsView {
2593 DnsView {
2594 cfg: DnsConfig {
2595 magic_dns: true,
2596 search_domains: vec!["user.ts.net".to_string()],
2597 routes,
2598 resolvers,
2599 fallback_resolvers: fallback,
2600 ..Default::default()
2601 },
2602 peers: None,
2603 self_node: None,
2604 exit_doh: None,
2605 enable_ipv6: false,
2606 accept_dns: true,
2607 }
2608 }
2609
2610 fn udp(addr: &str) -> DnsResolver {
2611 DnsResolver {
2612 transport: ts_control::ResolverTransport::Udp(addr.parse().unwrap()),
2613 use_with_exit_node: false,
2614 }
2615 }
2616
2617 #[test]
2618 fn split_dns_route_forwards_to_matching_upstream() {
2619 let mut routes = std::collections::BTreeMap::new();
2620 routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
2621 let view = view_with_routes(routes, vec![], vec![]);
2622 let buf = build_query(0x100, &["api", "corp", "example"], 1, 1);
2623
2624 match decide(&view, &buf).expect("decides") {
2625 Decision::Forward { upstreams, .. } => {
2626 assert_eq!(upstreams, vec!["10.0.0.53:53".parse().unwrap()]);
2627 }
2628 Decision::Reply(_) => panic!("expected forward to the split-DNS upstream"),
2629 }
2630 }
2631
2632 #[test]
2633 fn exotic_qtype_off_tailnet_forwards_to_upstream() {
2634 // The core of the fix: an HTTPS/SVCB (type 65) query for an off-tailnet name with a matching
2635 // route must FORWARD to the upstream (verbatim), exactly like an A query would — not REFUSE
2636 // and not NXDOMAIN. This is the browser HTTP/3 + ECH case the old blanket-REFUSE broke.
2637 let mut routes = std::collections::BTreeMap::new();
2638 routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
2639 let view = view_with_routes(routes, vec![], vec![]);
2640 let buf = build_query(0x102, &["api", "corp", "example"], 65, 1);
2641
2642 match decide(&view, &buf).expect("decides") {
2643 Decision::Forward {
2644 upstreams, query, ..
2645 } => {
2646 assert_eq!(upstreams, vec!["10.0.0.53:53".parse().unwrap()]);
2647 assert_eq!(query, buf, "the exotic-qtype query is forwarded verbatim");
2648 }
2649 Decision::Reply(_) => {
2650 panic!("an off-tailnet HTTPS-record query must forward, not reply")
2651 }
2652 }
2653 }
2654
2655 #[test]
2656 fn non_in_class_off_tailnet_forwards_to_upstream() {
2657 // A non-IN class for an off-tailnet routed name forwards too (Go does no class check on the
2658 // local path). Proves the class gate no longer short-circuits to REFUSED before routing.
2659 let mut routes = std::collections::BTreeMap::new();
2660 routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
2661 let view = view_with_routes(routes, vec![], vec![]);
2662 let buf = build_query(0x103, &["api", "corp", "example"], 1, 3);
2663
2664 match decide(&view, &buf).expect("decides") {
2665 Decision::Forward { upstreams, .. } => {
2666 assert_eq!(upstreams, vec!["10.0.0.53:53".parse().unwrap()]);
2667 }
2668 Decision::Reply(_) => {
2669 panic!("an off-tailnet non-IN-class query must forward, not reply")
2670 }
2671 }
2672 }
2673
2674 /// The local responder bounds concurrent in-flight forwards: `serve` acquires one
2675 /// `MAX_INFLIGHT_FORWARDS` permit per spawned forward task and drops the query fail-closed when
2676 /// the pool is exhausted (a client spraying forwardable names can't open unbounded overlay
2677 /// sockets). This pins the gating semantics `serve` relies on — drained pool refuses a new
2678 /// permit; releasing one restores capacity — and the cap constant itself. (The async `serve`
2679 /// loop has no netstack-free test seam, so the semaphore behavior is exercised directly here, the
2680 /// same `Arc<Semaphore>::try_acquire_owned` the loop uses.)
2681 #[test]
2682 fn forward_inflight_cap_fails_closed_when_saturated() {
2683 use std::sync::Arc;
2684
2685 use tokio::sync::Semaphore;
2686
2687 let inflight = Arc::new(Semaphore::new(MAX_INFLIGHT_FORWARDS));
2688
2689 // Drain every permit (one per concurrently in-flight forward).
2690 let mut held = Vec::with_capacity(MAX_INFLIGHT_FORWARDS);
2691 for _ in 0..MAX_INFLIGHT_FORWARDS {
2692 held.push(
2693 inflight
2694 .clone()
2695 .try_acquire_owned()
2696 .expect("permits available below the cap"),
2697 );
2698 }
2699
2700 // At the cap, the next forward is refused — `serve` would drop the query, not spawn.
2701 assert!(
2702 inflight.clone().try_acquire_owned().is_err(),
2703 "a saturated forward pool must refuse a new permit (fail closed)"
2704 );
2705
2706 // Completing an in-flight forward releases its permit and restores capacity.
2707 drop(held.pop());
2708 assert!(
2709 inflight.clone().try_acquire_owned().is_ok(),
2710 "releasing a permit must let the next forward proceed"
2711 );
2712 }
2713
2714 /// A permit moved into a spawned forward task (the `let _permit = permit;` shape `serve` uses)
2715 /// must stay held for the *whole* task body — across the `.await` on the upstream — and release
2716 /// only when the task completes. This guards the regression the saturation test above can't see:
2717 /// "tidying" `let _permit = permit;` to `let _ = permit;` would drop the permit immediately,
2718 /// re-opening unbounded concurrency while leaving the synchronous drain/restore test green. Here a
2719 /// 1-permit pool is consumed by a task that holds it across a yield; the pool must read empty
2720 /// while the task runs and refill once it finishes.
2721 #[tokio::test]
2722 async fn forward_permit_is_held_for_the_task_lifetime_not_dropped_early() {
2723 use std::sync::Arc;
2724
2725 use tokio::sync::Semaphore;
2726
2727 let inflight = Arc::new(Semaphore::new(1));
2728 let permit = inflight
2729 .clone()
2730 .try_acquire_owned()
2731 .expect("the sole permit is available");
2732
2733 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
2734 let (release_tx, release_rx) = tokio::sync::oneshot::channel();
2735 let task = tokio::spawn(async move {
2736 // Same shape as `serve`'s spawned forward: the permit is a named binding moved into the
2737 // task, so it lives until the body ends — not dropped at the `let`.
2738 let _permit = permit;
2739 started_tx.send(()).unwrap();
2740 // Stand in for the `.await` on the upstream forward.
2741 release_rx.await.unwrap();
2742 });
2743
2744 started_rx.await.unwrap();
2745 // While the task runs, the permit it moved in is still held — the pool is empty.
2746 assert!(
2747 inflight.clone().try_acquire_owned().is_err(),
2748 "a permit moved into a running task must stay held across its await"
2749 );
2750
2751 // Let the task finish; its permit drops with the body and capacity returns.
2752 release_tx.send(()).unwrap();
2753 task.await.unwrap();
2754 assert!(
2755 inflight.clone().try_acquire_owned().is_ok(),
2756 "the permit must be released once the task body completes"
2757 );
2758 }
2759
2760 /// The address of the `n`th fake upstream resolver (RFC 5737 documentation range).
2761 fn upstream_addr(n: u8) -> SocketAddr {
2762 SocketAddr::from((Ipv4Addr::new(198, 51, 100, n), 53))
2763 }
2764
2765 /// Turn `query` into an upstream response: echo the header and question back with `QR` set and
2766 /// `rcode` in the header's low nibble, then append `tail` verbatim, counted as `ancount` answer
2767 /// records. The forwarder relays bytes and never parses past the question, so an opaque tail is
2768 /// what tells two upstreams' responses apart — and stands in for the RFC 8914 extended DNS error
2769 /// a real resolver puts in its own SERVFAIL/REFUSED.
2770 fn upstream_response(query: &[u8], rcode: u8, ancount: u16, tail: &[u8]) -> Vec<u8> {
2771 let mut resp = query.to_vec();
2772 resp[2] |= 0x80; // QR = 1 (this is a response)
2773 resp[3] = (resp[3] & 0xF0) | rcode;
2774 resp[6..8].copy_from_slice(&ancount.to_be_bytes());
2775 resp.extend_from_slice(tail);
2776 resp
2777 }
2778
2779 /// One scripted upstream for [`run_forward_walk`]: the upstream's address, and the
2780 /// `(source address, datagram)` it hands back — `None` when nothing came back at all.
2781 type ScriptedUpstream = (SocketAddr, Option<(SocketAddr, Vec<u8>)>);
2782
2783 /// Run the real [`forward_walk`] over a scripted set of upstreams: each entry is
2784 /// `(upstream, answer)`, where `answer` is the `(source address, datagram)` that upstream hands
2785 /// back (`None` = nothing came back — a timeout, a bind/send/recv failure). Returns the bytes
2786 /// the client would get **and** the upstreams the walk actually asked, so a test can tell "the
2787 /// second upstream answered" apart from "the walk stopped at the first".
2788 ///
2789 /// The script stands in for [`ask_upstream`]'s overlay socket exchange only; every decision
2790 /// under test — the source/transaction-id check, the REFUSED/SERVFAIL soft-error rules, which
2791 /// response is relayed — is made by the production code being called.
2792 async fn run_forward_walk(
2793 script: &[ScriptedUpstream],
2794 query: &[u8],
2795 fallback: Vec<u8>,
2796 ) -> (Vec<u8>, Vec<SocketAddr>) {
2797 let upstreams: Vec<SocketAddr> = script.iter().map(|(upstream, _)| *upstream).collect();
2798 let asked = std::cell::RefCell::new(Vec::new());
2799
2800 let response = forward_walk(
2801 &upstreams,
2802 query,
2803 fallback,
2804 ClientTransport::Udp,
2805 |upstream| {
2806 asked.borrow_mut().push(upstream);
2807 let answer = script
2808 .iter()
2809 .find(|(scripted, _)| *scripted == upstream)
2810 .and_then(|(_, answer)| answer.clone());
2811 std::future::ready(answer)
2812 },
2813 )
2814 .await;
2815
2816 (response, asked.into_inner())
2817 }
2818
2819 /// A first upstream answering REFUSED must NOT end the forward. A broken or misconfigured
2820 /// resolver refuses instantly and would otherwise beat a healthy one that is still working,
2821 /// handing the stub resolver a refusal as though it were the answer — complete DNS failure
2822 /// wherever a split-DNS route or a fallback list names more than one resolver.
2823 #[tokio::test]
2824 async fn refused_first_upstream_does_not_end_the_walk() {
2825 let query = build_query(0x201, &["api", "example", "com"], 1, 1);
2826 let (first, second) = (upstream_addr(1), upstream_addr(2));
2827 let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"refused");
2828 let answer = upstream_response(&query, 0, 1, b"the real answer");
2829 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
2830
2831 let (got, asked) = run_forward_walk(
2832 &[
2833 (first, Some((first, refusal))),
2834 (second, Some((second, answer.clone()))),
2835 ],
2836 &query,
2837 fallback,
2838 )
2839 .await;
2840
2841 assert_eq!(
2842 asked,
2843 vec![first, second],
2844 "a REFUSED from the first upstream must not stop the walk"
2845 );
2846 assert_eq!(
2847 got, answer,
2848 "the healthy second upstream's answer is what reaches the client"
2849 );
2850 }
2851
2852 /// SERVFAIL is soft in the same way: the walk goes on and the healthy upstream's answer wins.
2853 #[tokio::test]
2854 async fn servfail_first_upstream_does_not_end_the_walk() {
2855 let query = build_query(0x202, &["api", "example", "com"], 1, 1);
2856 let (first, second) = (upstream_addr(1), upstream_addr(2));
2857 let soft_fail = upstream_response(&query, RCODE_SERVFAIL, 0, b"servfail");
2858 let answer = upstream_response(&query, 0, 1, b"the real answer");
2859 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
2860
2861 let (got, asked) = run_forward_walk(
2862 &[
2863 (first, Some((first, soft_fail))),
2864 (second, Some((second, answer.clone()))),
2865 ],
2866 &query,
2867 fallback,
2868 )
2869 .await;
2870
2871 assert_eq!(asked, vec![first, second], "SERVFAIL is a soft error too");
2872 assert_eq!(
2873 got, answer,
2874 "the second upstream's answer reaches the client"
2875 );
2876 }
2877
2878 /// An RCODE that is *not* soft is an answer: NXDOMAIN ends the walk where it is found, and the
2879 /// upstreams after it are never asked. (Making everything soft would turn a legitimate
2880 /// "no such name" into a needless extra round trip — and, with a second refusing upstream, into
2881 /// a different answer entirely.)
2882 #[tokio::test]
2883 async fn nxdomain_ends_the_walk_at_the_first_upstream() {
2884 let query = build_query(0x203, &["nope", "example", "com"], 1, 1);
2885 let (first, second) = (upstream_addr(1), upstream_addr(2));
2886 let nxdomain = upstream_response(&query, 3, 0, b"no such name");
2887 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
2888
2889 let (got, asked) = run_forward_walk(
2890 &[
2891 (first, Some((first, nxdomain.clone()))),
2892 (
2893 second,
2894 Some((second, upstream_response(&query, 0, 1, b"late"))),
2895 ),
2896 ],
2897 &query,
2898 fallback,
2899 )
2900 .await;
2901
2902 assert_eq!(asked, vec![first], "NXDOMAIN is an answer: stop asking");
2903 assert_eq!(got, nxdomain, "and it is what the client gets");
2904 }
2905
2906 /// When every upstream refuses, the client gets the FIRST refusal, byte for byte — not the
2907 /// caller's synthesized SERVFAIL. The upstream's own bytes can carry an RFC 8914 extended DNS
2908 /// error explaining the refusal; a locally built packet throws that away.
2909 #[tokio::test]
2910 async fn every_upstream_refusing_returns_the_first_refusal_verbatim() {
2911 let query = build_query(0x204, &["api", "example", "com"], 1, 1);
2912 let (first, second) = (upstream_addr(1), upstream_addr(2));
2913 let first_refusal =
2914 upstream_response(&query, RCODE_REFUSED, 0, b"first refusal + extended error");
2915 let second_refusal = upstream_response(&query, RCODE_REFUSED, 0, b"second refusal");
2916 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
2917
2918 let (got, asked) = run_forward_walk(
2919 &[
2920 (first, Some((first, first_refusal.clone()))),
2921 (second, Some((second, second_refusal.clone()))),
2922 ],
2923 &query,
2924 fallback.clone(),
2925 )
2926 .await;
2927
2928 assert_eq!(
2929 asked,
2930 vec![first, second],
2931 "every upstream is given its turn"
2932 );
2933 assert_eq!(
2934 got, first_refusal,
2935 "an all-refused forward relays the first upstream's own REFUSED bytes"
2936 );
2937 assert_ne!(
2938 got, fallback,
2939 "the synthesized SERVFAIL must not replace an upstream's own response"
2940 );
2941 assert_ne!(got, second_refusal, "the FIRST refusal is the one kept");
2942 }
2943
2944 /// The first *soft* response is the one kept whichever code it carried: a SERVFAIL followed by a
2945 /// REFUSED relays the upstream's own SERVFAIL, extended error and all, rather than the
2946 /// synthesized one the caller supplied.
2947 #[tokio::test]
2948 async fn every_upstream_soft_failing_returns_the_upstream_servfail_not_the_fallback() {
2949 let query = build_query(0x205, &["api", "example", "com"], 1, 1);
2950 let (first, second) = (upstream_addr(1), upstream_addr(2));
2951 let upstream_servfail = upstream_response(
2952 &query,
2953 RCODE_SERVFAIL,
2954 0,
2955 b"upstream servfail + extended error",
2956 );
2957 let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"second refusal");
2958 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
2959
2960 let (got, _asked) = run_forward_walk(
2961 &[
2962 (first, Some((first, upstream_servfail.clone()))),
2963 (second, Some((second, refusal))),
2964 ],
2965 &query,
2966 fallback.clone(),
2967 )
2968 .await;
2969
2970 assert_eq!(
2971 got, upstream_servfail,
2972 "the upstream's own SERVFAIL is relayed verbatim, keeping any extended DNS error"
2973 );
2974 assert_ne!(got, fallback, "not the locally synthesized SERVFAIL");
2975 }
2976
2977 /// A lone upstream that refuses still has its refusal relayed: with nothing else to wait for,
2978 /// treating REFUSED as soft changes nothing about what the client is told.
2979 #[tokio::test]
2980 async fn lone_refusing_upstream_still_has_its_refusal_relayed() {
2981 let query = build_query(0x206, &["api", "example", "com"], 1, 1);
2982 let only = upstream_addr(1);
2983 let refusal = upstream_response(&query, RCODE_REFUSED, 0, b"refused");
2984 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
2985
2986 let (got, asked) =
2987 run_forward_walk(&[(only, Some((only, refusal.clone())))], &query, fallback).await;
2988
2989 assert_eq!(asked, vec![only]);
2990 assert_eq!(
2991 got, refusal,
2992 "a single upstream's REFUSED is the client's answer"
2993 );
2994 }
2995
2996 /// The anti-poisoning check still runs BEFORE any of the soft-error handling: a datagram whose
2997 /// transaction id is not the one we asked with is discarded outright and never remembered as
2998 /// "the first REFUSED", so an off-path injector cannot plant the response an all-refused forward
2999 /// ends up relaying.
3000 #[tokio::test]
3001 async fn wrong_transaction_id_response_is_discarded_not_remembered_as_a_soft_error() {
3002 let query = build_query(0x207, &["api", "example", "com"], 1, 1);
3003 let only = upstream_addr(1);
3004 let mut poisoned = upstream_response(&query, RCODE_REFUSED, 0, b"injected");
3005 poisoned[0] ^= 0xFF; // a transaction id we never asked with
3006 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3007
3008 let (got, _asked) = run_forward_walk(
3009 &[(only, Some((only, poisoned.clone())))],
3010 &query,
3011 fallback.clone(),
3012 )
3013 .await;
3014
3015 assert_ne!(
3016 got, poisoned,
3017 "a mismatched transaction id must never be relayed"
3018 );
3019 assert_eq!(
3020 got, fallback,
3021 "with the datagram discarded nothing answered, so the synthesized fallback stands"
3022 );
3023 }
3024
3025 /// The same for the source check: a well-formed REFUSED that echoes the question and the
3026 /// transaction id but arrives from an address we did not query is discarded before it can become
3027 /// the forward's remembered soft error.
3028 #[tokio::test]
3029 async fn off_path_source_response_is_discarded_not_remembered_as_a_soft_error() {
3030 let query = build_query(0x208, &["api", "example", "com"], 1, 1);
3031 let (only, off_path) = (upstream_addr(1), upstream_addr(9));
3032 let poisoned = upstream_response(&query, RCODE_REFUSED, 0, b"injected");
3033 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3034
3035 let (got, _asked) = run_forward_walk(
3036 &[(only, Some((off_path, poisoned.clone())))],
3037 &query,
3038 fallback.clone(),
3039 )
3040 .await;
3041
3042 assert_ne!(
3043 got, poisoned,
3044 "a datagram from an unqueried source must never be relayed"
3045 );
3046 assert_eq!(
3047 got, fallback,
3048 "with the datagram discarded nothing answered, so the synthesized fallback stands"
3049 );
3050 }
3051
3052 /// An upstream that says nothing at all (timeout, bind/send/recv failure) is simply skipped, and
3053 /// the next upstream's answer is what the client gets.
3054 #[tokio::test]
3055 async fn silent_upstream_is_skipped_for_the_next_one() {
3056 let query = build_query(0x209, &["api", "example", "com"], 1, 1);
3057 let (first, second) = (upstream_addr(1), upstream_addr(2));
3058 let answer = upstream_response(&query, 0, 1, b"the real answer");
3059 let fallback = upstream_response(&query, RCODE_SERVFAIL, 0, b"synthesized");
3060
3061 let (got, asked) = run_forward_walk(
3062 &[(first, None), (second, Some((second, answer.clone())))],
3063 &query,
3064 fallback,
3065 )
3066 .await;
3067
3068 assert_eq!(asked, vec![first, second]);
3069 assert_eq!(got, answer);
3070 }
3071
3072 #[test]
3073 fn longest_suffix_route_wins() {
3074 let mut routes = std::collections::BTreeMap::new();
3075 routes.insert("example".to_string(), vec![udp("10.0.0.1:53")]);
3076 routes.insert("corp.example".to_string(), vec![udp("10.0.0.2:53")]);
3077 let view = view_with_routes(routes, vec![], vec![]);
3078 let buf = build_query(0x101, &["api", "corp", "example"], 1, 1);
3079
3080 match decide(&view, &buf).expect("decides") {
3081 Decision::Forward { upstreams, .. } => {
3082 assert_eq!(
3083 upstreams,
3084 vec!["10.0.0.2:53".parse().unwrap()],
3085 "longer suffix wins"
3086 );
3087 }
3088 Decision::Reply(_) => panic!("expected forward"),
3089 }
3090 }
3091
3092 #[test]
3093 fn negative_route_is_nxdomain_not_forwarded() {
3094 // An empty upstream list is a negative route: fail closed, never forward.
3095 let mut routes = std::collections::BTreeMap::new();
3096 routes.insert("blocked.example".to_string(), vec![]);
3097 let view = view_with_routes(routes, vec![udp("8.8.8.8:53")], vec![]);
3098 let buf = build_query(0x102, &["x", "blocked", "example"], 1, 1);
3099
3100 match decide(&view, &buf).expect("decides") {
3101 Decision::Reply(resp) => {
3102 let (_, rcode, _) = parse_header(&resp);
3103 assert_eq!(rcode, 3, "NxDomain: negative route is not forwarded");
3104 }
3105 Decision::Forward { .. } => panic!("negative route must not forward"),
3106 }
3107 }
3108
3109 #[test]
3110 fn unrouted_name_forwards_to_fallback_then_global() {
3111 // No route matches: fallback resolvers are preferred over global resolvers.
3112 let view = view_with_routes(
3113 std::collections::BTreeMap::new(),
3114 vec![udp("8.8.8.8:53")],
3115 vec![udp("1.1.1.1:53")],
3116 );
3117 let buf = build_query(0x103, &["example", "com"], 1, 1);
3118
3119 match decide(&view, &buf).expect("decides") {
3120 Decision::Forward { upstreams, .. } => {
3121 assert_eq!(
3122 upstreams,
3123 vec!["1.1.1.1:53".parse().unwrap()],
3124 "fallback preferred"
3125 );
3126 }
3127 Decision::Reply(_) => panic!("expected forward to fallback"),
3128 }
3129 }
3130
3131 #[test]
3132 fn unrouted_name_forwards_to_global_when_no_fallback() {
3133 let view = view_with_routes(
3134 std::collections::BTreeMap::new(),
3135 vec![udp("8.8.8.8:53")],
3136 vec![],
3137 );
3138 let buf = build_query(0x104, &["example", "com"], 1, 1);
3139
3140 match decide(&view, &buf).expect("decides") {
3141 Decision::Forward { upstreams, .. } => {
3142 assert_eq!(upstreams, vec!["8.8.8.8:53".parse().unwrap()]);
3143 }
3144 Decision::Reply(_) => panic!("expected forward to global resolver"),
3145 }
3146 }
3147
3148 #[test]
3149 fn tailnet_name_is_never_forwarded() {
3150 // Anti-leak: a name under a tailnet search domain that has no overlay match must fail
3151 // closed to NXDOMAIN, never leak to an upstream resolver, even with resolvers configured.
3152 let view = view_with_routes(
3153 std::collections::BTreeMap::new(),
3154 vec![udp("8.8.8.8:53")],
3155 vec![udp("1.1.1.1:53")],
3156 );
3157 // "ghost.user.ts.net" is under the tailnet suffix but matches no peer.
3158 let buf = build_query(0x105, &["ghost", "user", "ts", "net"], 1, 1);
3159
3160 match decide(&view, &buf).expect("decides") {
3161 Decision::Reply(resp) => {
3162 let (_, rcode, _) = parse_header(&resp);
3163 assert_eq!(rcode, 3, "NxDomain: tailnet name not leaked upstream");
3164 }
3165 Decision::Forward { .. } => panic!("tailnet name must never be forwarded"),
3166 }
3167 }
3168
3169 #[test]
3170 fn no_resolvers_off_tailnet_is_servfail_not_nxdomain() {
3171 // No route, no resolvers: an OFF-tailnet name cannot be forwarded. Go answers SERVFAIL
3172 // (forwarder.go:1207 "no upstream resolvers set, returning SERVFAIL"), NOT NXDOMAIN — a
3173 // cacheable non-existence for a real name we merely couldn't forward would poison downstream
3174 // stub caches. We still never forward (the name does not leak); we just soft-fail.
3175 let view = view_with_routes(std::collections::BTreeMap::new(), vec![], vec![]);
3176 let buf = build_query(0x106, &["example", "com"], 1, 1);
3177
3178 match decide(&view, &buf).expect("decides") {
3179 Decision::Reply(resp) => {
3180 let (_, rcode, _) = parse_header(&resp);
3181 assert_eq!(
3182 rcode, 2,
3183 "ServFail: off-tailnet name with no upstream to forward to"
3184 );
3185 }
3186 Decision::Forward { .. } => panic!("must not forward with no resolvers"),
3187 }
3188 }
3189
3190 #[test]
3191 fn route_with_only_ipv6_upstreams_off_tailnet_is_servfail() {
3192 // A split-DNS route exists but every resolver is IPv6 (filtered out under the IPv4-only
3193 // egress): we have a route yet nowhere to forward. That is an inability to forward an
3194 // off-tailnet name, so SERVFAIL (soft), not a fabricated NXDOMAIN.
3195 let mut routes = std::collections::BTreeMap::new();
3196 routes.insert("corp.example".to_string(), vec![udp("[2001:db8::53]:53")]);
3197 let view = view_with_routes(routes, vec![], vec![]);
3198 let buf = build_query(0x108, &["host", "corp", "example"], 1, 1);
3199
3200 match decide(&view, &buf).expect("decides") {
3201 Decision::Reply(resp) => {
3202 let (_, rcode, _) = parse_header(&resp);
3203 assert_eq!(
3204 rcode, 2,
3205 "ServFail: route's resolvers all filtered out (IPv6-only), cannot forward"
3206 );
3207 }
3208 Decision::Forward { .. } => panic!("must not forward when all upstreams are filtered"),
3209 }
3210 }
3211
3212 #[test]
3213 fn overlay_match_wins_over_forwarding() {
3214 // A known peer name resolves authoritatively even when upstream resolvers are configured.
3215 let mut db = PeerDb::default();
3216 db.upsert(&test_node());
3217 let view = DnsView {
3218 cfg: DnsConfig {
3219 magic_dns: true,
3220 search_domains: vec!["user.ts.net".to_string()],
3221 resolvers: vec![udp("8.8.8.8:53")],
3222 ..Default::default()
3223 },
3224 peers: Some(Arc::new(db)),
3225 self_node: None,
3226 exit_doh: None,
3227 enable_ipv6: false,
3228 accept_dns: true,
3229 };
3230 let buf = build_query(0x107, &["host", "user", "ts", "net"], 1, 1);
3231
3232 match decide(&view, &buf).expect("decides") {
3233 Decision::Reply(resp) => {
3234 let (_, rcode, ancount) = parse_header(&resp);
3235 assert_eq!(rcode, 0, "authoritative answer wins");
3236 assert_eq!(ancount, 1);
3237 }
3238 Decision::Forward { .. } => panic!("overlay match must not forward"),
3239 }
3240 }
3241
3242 #[test]
3243 fn ipv6_reverse_ptr_is_nxdomain_not_forwarded() {
3244 // Anti-leak: an `ip6.arpa` reverse PTR for a tailnet ULA (fd7a:…) must fail closed to
3245 // NXDOMAIN, never be forwarded — even with an upstream resolver configured. This fork is
3246 // IPv4-only on the tailnet; forwarding would reveal that a v6 address was probed.
3247 let view = view_with_routes(
3248 std::collections::BTreeMap::new(),
3249 vec![udp("8.8.8.8:53")],
3250 vec![udp("1.1.1.1:53")],
3251 );
3252 // Reverse name for fd7a::1 (nibble-reversed) under ip6.arpa. The exact nibble labels don't
3253 // matter to the guard — any name ending in ip6.arpa must fail closed.
3254 let labels = vec![
3255 "1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0",
3256 "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "a", "7", "d", "f", "ip6",
3257 "arpa",
3258 ];
3259 let buf = build_query(0x200, &labels, 12, 1);
3260
3261 match decide(&view, &buf).expect("decides") {
3262 Decision::Reply(resp) => {
3263 let (_, rcode, _) = parse_header(&resp);
3264 assert_eq!(
3265 rcode, 3,
3266 "NxDomain: ip6.arpa reverse must not leak upstream"
3267 );
3268 }
3269 Decision::Forward { .. } => panic!("ip6.arpa PTR must never be forwarded"),
3270 }
3271 }
3272
3273 /// The `TC` bit a truncated UDP answer sets is what sends a stub resolver to TCP (RFC 1035
3274 /// §4.2.1). Setting it *again* on the TCP answer sends that resolver straight back into another
3275 /// retry, so the client's advertised UDP payload size — a property of the datagram it would
3276 /// have been answered in, and one RFC 7766 §8 gives a TCP client no equivalent of — is applied
3277 /// only to a [`ClientTransport::Udp`] client. Same query, same answer, two transports.
3278 #[test]
3279 fn client_udp_limit_is_not_applied_to_a_tcp_client() {
3280 // No EDNS OPT record, so the client's limit is the classic 512 bytes.
3281 let query = build_query(0x310, &["example", "com"], 1, 1);
3282 let mut answer = query.clone();
3283 answer[2] |= 0x80; // make it a response (QR=1)
3284 answer.resize(900, 0xAB); // over 512, under MAX_UPSTREAM_RESPONSE: only the client limit bites
3285
3286 let udp = cap_response(&query, answer.clone(), ClientTransport::Udp);
3287 assert_ne!(
3288 udp[2] & 0x02,
3289 0,
3290 "a UDP client that advertised 512 bytes is told the 900-byte answer is truncated"
3291 );
3292 assert_eq!(udp.len(), 900, "and the body is left intact either way");
3293
3294 let tcp = cap_response(&query, answer, ClientTransport::Tcp);
3295 assert_eq!(
3296 tcp[2] & 0x02,
3297 0,
3298 "the same answer over TCP is NOT marked: the client already did the TCP retry"
3299 );
3300 assert_eq!(tcp.len(), 900, "and is relayed whole");
3301 }
3302
3303 /// The relay cap is a different claim from the client's datagram size, and it holds on both
3304 /// transports: when [`MAX_UPSTREAM_RESPONSE`] really did cut the message, `TC` says so. Handing
3305 /// a TCP client a chopped body with `TC` clear would be a malformed-but-"complete" answer.
3306 #[test]
3307 fn a_chopped_answer_is_marked_truncated_on_both_transports() {
3308 let query = build_edns_query(0x311, &["example", "com"], 1, 1, 4096);
3309 let mut big = query.clone();
3310 big[2] |= 0x80;
3311 big.resize(MAX_UPSTREAM_RESPONSE + 500, 0xAB);
3312
3313 let out = cap_response(&query, big, ClientTransport::Tcp);
3314 assert_eq!(out.len(), MAX_UPSTREAM_RESPONSE, "capped to one datagram");
3315 assert_ne!(
3316 out[2] & 0x02,
3317 0,
3318 "we really did chop the body, so TC is set for a TCP client too"
3319 );
3320 }
3321
3322 #[test]
3323 fn cap_response_sets_tc_when_truncated() {
3324 // An oversize upstream answer is capped to a single datagram AND marked truncated (TC bit)
3325 // so the stub resolver retries over TCP rather than trusting a chopped message. The query
3326 // advertises a big EDNS buffer so only the relay cap can be what fires here.
3327 let query = build_edns_query(0x300, &["example", "com"], 1, 1, 4096);
3328 let mut big = query.clone();
3329 big[2] |= 0x80; // make it a response (QR=1)
3330 big.resize(MAX_UPSTREAM_RESPONSE + 500, 0xAB);
3331
3332 let out = cap_response(&query, big, ClientTransport::Udp);
3333 assert_eq!(out.len(), MAX_UPSTREAM_RESPONSE, "capped to one datagram");
3334 assert_ne!(out[2] & 0x02, 0, "TC bit set on truncation");
3335 }
3336
3337 #[test]
3338 fn cap_response_leaves_small_response_untouched() {
3339 // A response that fits both bounds is returned verbatim with no TC bit forced on.
3340 let query = build_query(0x301, &["example", "com"], 1, 1);
3341 let mut small = query.clone();
3342 small[2] |= 0x80;
3343 let before = small.clone();
3344
3345 let out = cap_response(&query, small, ClientTransport::Udp);
3346 assert_eq!(out, before, "small response unchanged");
3347 assert_eq!(out[2] & 0x02, 0, "TC bit not set when no truncation");
3348 }
3349
3350 #[test]
3351 fn cap_is_a_relay_bound_not_the_read_bound() {
3352 // `forward_query` reads with `recv_from_bytes`, which issues `Recv { max_len: None }`, so
3353 // the netstack has already copied the whole datagram out before `cap_response` runs: the
3354 // cap bounds what we relay, not what we read or allocate. What bounds the read is the
3355 // netstack UDP socket's receive ring (`udp_buffer_size`, which `ts_runtime` leaves at the
3356 // `netcore` default) -- smoltcp drops a datagram larger than that ring at enqueue instead
3357 // of delivering it, and hands us everything up to and including the ring whole. The ring
3358 // being *wider* than the cap is what shows the two are different bounds: the read can put
3359 // more bytes in front of `cap_response` than the cap will relay.
3360 let ring = netstack::netcore::Config::default().udp_buffer_size;
3361 assert!(
3362 ring > MAX_UPSTREAM_RESPONSE,
3363 "the netstack udp receive ring ({ring}) no longer exceeds the relay cap \
3364 ({MAX_UPSTREAM_RESPONSE}): the cap would then be unreachable through this socket, and \
3365 the doc describing it as a relay bound the read can overrun is wrong"
3366 );
3367
3368 // The largest answer the cap passes is relayed byte-for-byte. Ask with an EDNS buffer that
3369 // covers the whole datagram, so the client-limit check (the other half of `cap_response`)
3370 // is not what we are measuring.
3371 let query = build_edns_query(0x302, &["example", "com"], 1, 1, 4096);
3372 let mut largest = query.clone();
3373 largest[2] |= 0x80; // QR=1
3374 largest.resize(MAX_UPSTREAM_RESPONSE, 0xAB);
3375 let before = largest.clone();
3376
3377 let out = cap_response(&query, largest, ClientTransport::Udp);
3378 assert_eq!(out, before, "an answer at the cap must be relayed verbatim");
3379 assert_eq!(
3380 out[2] & 0x02,
3381 0,
3382 "TC must not be set on a datagram that was never chopped"
3383 );
3384 }
3385
3386 #[test]
3387 fn full_ring_datagram_is_chopped_and_marked_truncated() {
3388 // Upstream's bound is `const maxResponseBytes = 4095` (net/dns/resolver/tsdns.go @
3389 // 9ea7cba44591e0cd840c6c94d23274dd222059bf). `sendUDP` reads into `maxResponseBytes+1`
3390 // bytes exactly so a 4096-byte answer is detectable as "did not fit", then cuts it to 4095
3391 // and sets TC. Here the netstack's 4096-byte receive ring plays the part of Go's `+1`: a
3392 // full-ring datagram is the one deliverable size the cap does not pass, and it must come
3393 // back with the same shape a Go forwarder would have produced. With the cap at 4096 this
3394 // datagram was relayed whole with TC clear, while a Go client on the same tailnet answering
3395 // the same query returned 4095 bytes marked truncated.
3396 let ring = netstack::netcore::Config::default().udp_buffer_size;
3397 let query = build_edns_query(0x303, &["example", "com"], 1, 1, 4096);
3398 let mut full_ring = query.clone();
3399 full_ring[2] |= 0x80; // QR=1
3400 full_ring.resize(ring, 0xAB);
3401
3402 let out = cap_response(&query, full_ring, ClientTransport::Udp);
3403 assert_eq!(
3404 out.len(),
3405 4095,
3406 "a full-ring answer must be cut to upstream's maxResponseBytes"
3407 );
3408 assert_ne!(out[2] & 0x02, 0, "TC bit set on the chopped answer");
3409 }
3410
3411 #[test]
3412 fn forwarded_reply_over_512_sets_tc_for_a_plain_query() {
3413 // A query with no EDNS OPT record is limited to 512 bytes (RFC 1035), so a 900-byte
3414 // forwarded reply -- well under the 4095 relay cap, and therefore relayed with TC clear
3415 // before this check existed -- must come back marked truncated, body intact.
3416 let query = build_query(0x400, &["example", "com"], 1, 1);
3417 let mut reply = query.clone();
3418 reply[2] |= 0x80; // QR=1
3419 reply.resize(900, 0xAB);
3420
3421 let out = cap_response(&query, reply.clone(), ClientTransport::Udp);
3422
3423 assert_ne!(
3424 out[2] & 0x02,
3425 0,
3426 "a 900-byte reply to a non-EDNS query must have TC set"
3427 );
3428 assert_eq!(out.len(), 900, "the body is left intact, not chopped");
3429 assert_eq!(
3430 out[3..],
3431 reply[3..],
3432 "only the flags byte carrying TC may differ"
3433 );
3434 }
3435
3436 #[test]
3437 fn forwarded_reply_under_advertised_edns_size_leaves_tc_clear() {
3438 // The same 900-byte reply, but the client advertised a 4096-byte EDNS buffer: it fits, so
3439 // TC must stay clear and the datagram must be relayed byte-for-byte.
3440 let query = build_edns_query(0x401, &["example", "com"], 1, 1, 4096);
3441 let mut reply = query.clone();
3442 reply[2] |= 0x80; // QR=1
3443 reply.resize(900, 0xAB);
3444 let before = reply.clone();
3445
3446 let out = cap_response(&query, reply, ClientTransport::Udp);
3447
3448 assert_eq!(
3449 out, before,
3450 "a reply within the advertised buffer is verbatim"
3451 );
3452 assert_eq!(out[2] & 0x02, 0, "TC must stay clear");
3453 }
3454
3455 /// Go's `findOPTRecord` accepts an OPT record only in the final 11 bytes of the message, with a
3456 /// root NAME, EDNS version 0 and `RDLEN == 0`; anything else is "no EDNS", i.e. the 512-byte
3457 /// RFC 1035 limit. Every rejection below is a case where a laxer reader would honour a large
3458 /// advertised buffer and leave `TC` clear on an answer a Go node marks truncated.
3459 #[test]
3460 fn client_udp_limit_reads_the_opt_record() {
3461 // No OPT record => the RFC 1035 512-byte limit.
3462 let plain = build_query(0x402, &["example", "com"], 1, 1);
3463 assert_eq!(client_udp_limit(&plain), NO_EDNS_UDP_LIMIT);
3464
3465 // An OPT record's CLASS field carries the advertised size.
3466 let edns = build_edns_query(0x403, &["example", "com"], 1, 1, 1232);
3467 assert_eq!(client_udp_limit(&edns), 1232);
3468
3469 // A value below 512 is taken verbatim. RFC 6891 6.2.3 would floor it at 512, but Go does
3470 // not (`maxSize = int(ednsSize)`), so a Rust node that did would leave `TC` clear where a
3471 // Go node on the same tailnet sets it.
3472 let tiny = build_edns_query(0x404, &["example", "com"], 1, 1, 64);
3473 assert_eq!(client_udp_limit(&tiny), 64);
3474
3475 // An OPT record that is not the last record in the message is not read at all: upstream
3476 // only ever looks at the final 11 bytes.
3477 let mut trailing_rr = build_edns_query(0x405, &["example", "com"], 1, 1, 2048);
3478 // A 1-byte-RDATA TXT (type 16) record for the root name, appended after the OPT.
3479 trailing_rr.extend_from_slice(&[0, 0, 16, 0, 1, 0, 0, 0, 0, 0, 1, 0]);
3480 trailing_rr[11] = 2; // ARCOUNT = 2
3481 assert_eq!(client_udp_limit(&trailing_rr), NO_EDNS_UDP_LIMIT);
3482
3483 // An OPT record carrying options — a DNS cookie, EDNS Client Subnet — has RDLEN != 0 and is
3484 // rejected. This is the common case, not a corner: stub resolvers send cookies routinely.
3485 let cookie =
3486 build_edns_query_with_option(0x406, &["example", "com"], 1, 1, 4096, 10, &[0; 8]);
3487 assert_eq!(client_udp_limit(&cookie), NO_EDNS_UDP_LIMIT);
3488
3489 // An unknown EDNS version is left alone rather than guessed at.
3490 let mut future_version = build_edns_query(0x407, &["example", "com"], 1, 1, 4096);
3491 let ttl_at = future_version.len() - 6; // TTL = extended RCODE (1) | VERSION (1) | flags (2)
3492 future_version[ttl_at + 1] = 1; // EDNS version 1
3493 assert_eq!(client_udp_limit(&future_version), NO_EDNS_UDP_LIMIT);
3494
3495 // A non-root OPT NAME is rejected.
3496 let mut named = build_edns_query(0x408, &["example", "com"], 1, 1, 4096);
3497 let name_at = named.len() - 11;
3498 named[name_at] = 0xC0; // a compression pointer where the root label must be
3499 assert_eq!(client_udp_limit(&named), NO_EDNS_UDP_LIMIT);
3500
3501 // ARCOUNT == 0 means there is no additional section to hold an OPT, whatever the trailing
3502 // bytes happen to look like.
3503 let mut no_ar = build_edns_query(0x409, &["example", "com"], 1, 1, 4096);
3504 no_ar[11] = 0;
3505 assert_eq!(client_udp_limit(&no_ar), NO_EDNS_UDP_LIMIT);
3506
3507 // A truncated message falls back to the conservative limit, never a larger one.
3508 let mut chopped = build_edns_query(0x40A, &["example", "com"], 1, 1, 4096);
3509 chopped.truncate(chopped.len() - 8);
3510 assert_eq!(client_udp_limit(&chopped), NO_EDNS_UDP_LIMIT);
3511 }
3512
3513 /// The whole point of the narrow OPT reader, end to end: a stub resolver that advertises 4096
3514 /// **and** sends a DNS cookie is capped at 512, so the 900-byte forwarded reply comes back with
3515 /// `TC` set. A reader that walked the additional section properly would honour the 4096 and
3516 /// leave `TC` clear — which is the answer no Go node on the tailnet would have produced.
3517 #[test]
3518 fn an_opt_record_carrying_options_is_not_honoured() {
3519 let query =
3520 build_edns_query_with_option(0x40B, &["example", "com"], 1, 1, 4096, 10, &[0; 8]);
3521 let mut reply = query.clone();
3522 reply[2] |= 0x80; // QR=1
3523 reply.resize(900, 0xAB);
3524
3525 let out = cap_response(&query, reply, ClientTransport::Udp);
3526 assert_ne!(
3527 out[2] & 0x02,
3528 0,
3529 "an OPT record with options is no EDNS at all upstream: the 512-byte limit applies"
3530 );
3531 assert_eq!(out.len(), 900, "the body is left intact, not chopped");
3532 }
3533
3534 /// An advertised size below 512 is honoured as-is. Go floors nothing: `maxSize = int(ednsSize)`
3535 /// whenever an OPT record is present, and only a request with no OPT record falls back to 512.
3536 #[test]
3537 fn an_advertised_size_below_512_is_not_floored() {
3538 let query = build_edns_query(0x40C, &["example", "com"], 1, 1, 200);
3539 let mut reply = query.clone();
3540 reply[2] |= 0x80; // QR=1
3541 reply.resize(300, 0xAB);
3542
3543 let out = cap_response(&query, reply, ClientTransport::Udp);
3544 assert_ne!(
3545 out[2] & 0x02,
3546 0,
3547 "300 bytes overflows the 200 the client asked for, so TC is set"
3548 );
3549 assert_eq!(out.len(), 300, "the body is left intact, not chopped");
3550 }
3551
3552 /// Upstream runs the size check on answers the resolver builds itself, not only on forwarded
3553 /// ones (`Resolver.Query` calls `checkResponseSizeAndSetTC` right after `respond` succeeds). An
3554 /// authoritative answer is capped at 512 bytes, which says nothing about a client that
3555 /// advertised less than that.
3556 #[test]
3557 fn an_authoritative_answer_over_the_advertised_size_is_marked() {
3558 let view = view_with_peer();
3559 let buf = build_edns_query(0x40D, &["host", "user", "ts", "net"], 1, 1, 20);
3560
3561 let resp = answer(&view, &buf).expect("answers");
3562 assert!(
3563 resp.len() > 20,
3564 "the fixture only works if the answer overflows the advertised 20 bytes"
3565 );
3566
3567 let marked = check_response_size_and_set_tc(&buf, resp.clone(), ClientTransport::Udp);
3568 assert_ne!(
3569 marked[2] & 0x02,
3570 0,
3571 "an answer we composed ourselves can still overflow a small advertised buffer"
3572 );
3573 assert_eq!(marked.len(), resp.len(), "the body is left intact");
3574 assert_eq!(
3575 marked[3..],
3576 resp[3..],
3577 "only the flags byte carrying TC may differ"
3578 );
3579 }
3580
3581 #[test]
3582 fn response_matches_query_rejects_mismatched_question() {
3583 // id + QR match but the echoed question differs (different QNAME) => rejected. This guards
3584 // against an off-path injector that guesses the id but answers a different question.
3585 let query = build_query(0x1234, &["a", "com"], 1, 1);
3586
3587 let mut wrong_question = build_query(0x1234, &["b", "com"], 1, 1);
3588 wrong_question[2] |= 0x80; // QR=1, same id
3589 assert!(
3590 !response_matches_query(&query, &wrong_question),
3591 "different QNAME must be rejected"
3592 );
3593
3594 // A different QTYPE with the same name is also rejected.
3595 let mut wrong_qtype = build_query(0x1234, &["a", "com"], 28, 1);
3596 wrong_qtype[2] |= 0x80;
3597 assert!(
3598 !response_matches_query(&query, &wrong_qtype),
3599 "different QTYPE must be rejected"
3600 );
3601
3602 // The exact echoed question with QR=1 is accepted.
3603 let mut good = query.clone();
3604 good[2] |= 0x80;
3605 assert!(
3606 response_matches_query(&query, &good),
3607 "matching question accepted"
3608 );
3609 }
3610
3611 #[test]
3612 fn suffix_matches_handles_boundaries_and_empty() {
3613 // Exact and label-boundary matches.
3614 assert!(suffix_matches("corp", "corp"));
3615 assert!(suffix_matches("a.corp", "corp"));
3616 assert!(suffix_matches("a.b.corp", "corp"));
3617 // Not a label boundary.
3618 assert!(!suffix_matches("acorp", "corp"));
3619 // Empty suffix never matches (defense-in-depth against `ends_with("")`).
3620 assert!(!suffix_matches("anything.example", ""));
3621 assert!(!suffix_matches("", ""));
3622 }
3623
3624 #[test]
3625 fn empty_search_domain_does_not_capture_everything() {
3626 // Defense-in-depth: an empty search domain must NOT make every name look like a tailnet
3627 // name (which would fail-close legitimate recursive queries / mis-route). With an empty
3628 // suffix present alongside a real resolver, an off-tailnet name still forwards.
3629 let mut view = view_with_routes(
3630 std::collections::BTreeMap::new(),
3631 vec![udp("8.8.8.8:53")],
3632 vec![],
3633 );
3634 view.cfg.search_domains = vec![String::new()];
3635 let buf = build_query(0x400, &["example", "com"], 1, 1);
3636
3637 match decide(&view, &buf).expect("decides") {
3638 Decision::Forward { upstreams, .. } => {
3639 assert_eq!(upstreams, vec!["8.8.8.8:53".parse().unwrap()]);
3640 }
3641 Decision::Reply(_) => {
3642 panic!("empty search domain must not treat every name as tailnet")
3643 }
3644 }
3645 }
3646
3647 #[test]
3648 fn empty_route_suffix_does_not_capture_everything() {
3649 // Defense-in-depth: an empty route suffix must not match every name (which would route all
3650 // queries to that route's upstreams). With an empty-suffix route present, an unrelated name
3651 // still falls through to the global resolver.
3652 let mut routes = std::collections::BTreeMap::new();
3653 routes.insert(String::new(), vec![udp("10.9.9.9:53")]);
3654 let view = view_with_routes(routes, vec![udp("8.8.8.8:53")], vec![]);
3655 let buf = build_query(0x401, &["example", "com"], 1, 1);
3656
3657 match decide(&view, &buf).expect("decides") {
3658 Decision::Forward { upstreams, .. } => {
3659 assert_eq!(
3660 upstreams,
3661 vec!["8.8.8.8:53".parse().unwrap()],
3662 "empty route suffix must not capture; falls through to global"
3663 );
3664 }
3665 Decision::Reply(_) => panic!("expected forward to global resolver"),
3666 }
3667 }
3668
3669 fn udp_exit(addr: &str) -> DnsResolver {
3670 DnsResolver {
3671 transport: ts_control::ResolverTransport::Udp(addr.parse().unwrap()),
3672 use_with_exit_node: true,
3673 }
3674 }
3675
3676 #[test]
3677 fn recursive_forward_is_flagged_route_forward_is_not() {
3678 // A recursive (global/fallback) forward sets `recursive = true` (eligible for DoH
3679 // delegation); a deliberately-configured split-DNS route sets `recursive = false`.
3680 let mut routes = std::collections::BTreeMap::new();
3681 routes.insert("corp.example".to_string(), vec![udp("10.0.0.53:53")]);
3682 let view = view_with_routes(routes, vec![udp("8.8.8.8:53")], vec![]);
3683
3684 let routed = build_query(0x500, &["api", "corp", "example"], 1, 1);
3685 match decide(&view, &routed).expect("decides") {
3686 Decision::Forward { recursive, .. } => {
3687 assert!(!recursive, "split-DNS route is not a recursive forward")
3688 }
3689 Decision::Reply(_) => panic!("expected route forward"),
3690 }
3691
3692 let global = build_query(0x501, &["example", "com"], 1, 1);
3693 match decide(&view, &global).expect("decides") {
3694 Decision::Forward { recursive, .. } => {
3695 assert!(recursive, "unrouted name is a recursive forward")
3696 }
3697 Decision::Reply(_) => panic!("expected recursive forward"),
3698 }
3699 }
3700
3701 #[test]
3702 fn recursive_plan_keeps_udp_without_exit_node() {
3703 // No active exit node: a recursive forward stays on its default UDP upstreams.
3704 let view = view_with_routes(
3705 std::collections::BTreeMap::new(),
3706 vec![udp("8.8.8.8:53")],
3707 vec![],
3708 );
3709 let default = vec!["8.8.8.8:53".parse().unwrap()];
3710 assert_eq!(
3711 recursive_plan(&view, default.clone()),
3712 RecursivePlan::Udp(default)
3713 );
3714 }
3715
3716 #[test]
3717 fn recursive_plan_delegates_to_doh_with_exit_node() {
3718 // Exit node active, no kept-local resolvers: recursive queries delegate to the exit node's
3719 // DoH endpoint so resolution egresses from the exit node, not this host.
3720 let mut view = view_with_routes(
3721 std::collections::BTreeMap::new(),
3722 vec![udp("8.8.8.8:53")],
3723 vec![],
3724 );
3725 let doh: SocketAddr = "100.64.0.5:8080".parse().unwrap();
3726 view.exit_doh = Some(doh);
3727 assert_eq!(
3728 recursive_plan(&view, vec!["8.8.8.8:53".parse().unwrap()]),
3729 RecursivePlan::Doh(doh)
3730 );
3731 }
3732
3733 #[test]
3734 fn recursive_plan_keeps_use_with_exit_node_resolvers_local() {
3735 // Even with an exit node active, resolvers flagged `use_with_exit_node` stay local (Go keeps
3736 // UseWithExitNode resolvers). The plan forwards to those over UDP, never delegating to DoH.
3737 let mut view = view_with_routes(
3738 std::collections::BTreeMap::new(),
3739 vec![udp_exit("10.0.0.53:53"), udp("8.8.8.8:53")],
3740 vec![],
3741 );
3742 view.exit_doh = Some("100.64.0.5:8080".parse().unwrap());
3743 // The default upstreams the caller computed are irrelevant when kept-local resolvers exist;
3744 // the plan must use the kept-local ones.
3745 assert_eq!(
3746 recursive_plan(&view, vec!["8.8.8.8:53".parse().unwrap()]),
3747 RecursivePlan::Udp(vec!["10.0.0.53:53".parse().unwrap()])
3748 );
3749 }
3750
3751 // --- SOA on authoritative negative answers (RFC 2308) -----------------------------------
3752
3753 /// Read an uncompressed name at `off`, returning it dotted and the offset just past it.
3754 fn read_name(resp: &[u8], mut off: usize) -> (String, usize) {
3755 let mut labels: Vec<String> = Vec::new();
3756 loop {
3757 let len = resp[off] as usize;
3758 assert_eq!(len & 0xC0, 0, "no compression pointer expected here");
3759 off += 1;
3760 if len == 0 {
3761 break;
3762 }
3763 labels.push(String::from_utf8(resp[off..off + len].to_vec()).expect("ascii label"));
3764 off += len;
3765 }
3766 (labels.join("."), off)
3767 }
3768
3769 /// The number of records in a response's authority section (NSCOUNT).
3770 fn nscount(resp: &[u8]) -> u16 {
3771 u16::from_be_bytes([resp[8], resp[9]])
3772 }
3773
3774 /// Walk an answer-less response to its authority section and read the SOA there, returning
3775 /// `(zone, record TTL, SERIAL, MINIMUM)`. `None` when the authority section is empty.
3776 ///
3777 /// Also asserts the record's shape as it goes: TYPE=SOA, CLASS=IN, and MNAME/RNAME both equal
3778 /// the owner name (the placeholders Go writes).
3779 fn parse_soa(resp: &[u8]) -> Option<(String, u32, u32, u32)> {
3780 let (.., ancount) = parse_header(resp);
3781 assert_eq!(ancount, 0, "parse_soa only walks answer-less responses");
3782 if nscount(resp) == 0 {
3783 return None;
3784 }
3785 assert_eq!(nscount(resp), 1, "at most one SOA");
3786
3787 // Question: QNAME then QTYPE + QCLASS.
3788 let (_, off) = read_name(resp, 12);
3789 // Authority record: NAME, TYPE, CLASS, TTL, RDLENGTH, RDATA.
3790 let (zone, off) = read_name(resp, off + 4);
3791 let u16_at = |at: usize| u16::from_be_bytes([resp[at], resp[at + 1]]);
3792 let u32_at = |at: usize| u32::from_be_bytes(resp[at..at + 4].try_into().unwrap());
3793 assert_eq!(u16_at(off), 6, "TYPE = SOA");
3794 assert_eq!(u16_at(off + 2), 1, "CLASS = IN");
3795 let ttl = u32_at(off + 4);
3796 let rdlength = u16_at(off + 8) as usize;
3797
3798 // RDATA: MNAME, RNAME, SERIAL, REFRESH, RETRY, EXPIRE, MINIMUM.
3799 let rdata_start = off + 10;
3800 let (mname, off) = read_name(resp, rdata_start);
3801 let (rname, off) = read_name(resp, off);
3802 assert_eq!(mname, zone, "MNAME is the zone (placeholder)");
3803 assert_eq!(rname, zone, "RNAME is the zone (placeholder)");
3804 let serial = u32_at(off);
3805 let minimum = u32_at(off + 16);
3806 assert_eq!(
3807 off + 20 - rdata_start,
3808 rdlength,
3809 "RDLENGTH covers exactly the SOA fields"
3810 );
3811 assert_eq!(resp.len(), off + 20, "the SOA is the last record");
3812 Some((zone, ttl, serial, minimum))
3813 }
3814
3815 /// Roughly-now, for asserting the SOA SERIAL is a unix timestamp rather than a constant.
3816 fn now_unix() -> u32 {
3817 std::time::SystemTime::now()
3818 .duration_since(std::time::UNIX_EPOCH)
3819 .expect("clock after the epoch")
3820 .as_secs() as u32
3821 }
3822
3823 /// An NXDOMAIN for a name under a tailnet search domain is authoritative, so it carries that
3824 /// search domain's SOA with the 10-second negative TTL. Without it a downstream cache picks its
3825 /// own (much longer) negative lifetime and a node renamed to that name stays unresolvable.
3826 #[test]
3827 fn nxdomain_for_tailnet_name_carries_the_search_domain_soa() {
3828 let view = view_with_peer();
3829 let buf = build_query(0x1111, &["nope", "user", "ts", "net"], 1, 1);
3830
3831 let resp = answer(&view, &buf).expect("answers");
3832 let (_, rcode, ancount) = parse_header(&resp);
3833 assert_eq!(rcode, 3, "NXDOMAIN");
3834 assert_eq!(ancount, 0);
3835
3836 let (zone, ttl, serial, minimum) =
3837 parse_soa(&resp).expect("an SOA in the authority section");
3838 assert_eq!(zone, "user.ts.net", "the search domain containing the name");
3839 assert_eq!(ttl, 10, "negative TTL");
3840 assert_eq!(minimum, 10, "MINIMUM also bounds negative caching");
3841 // The serial is the response time in unix seconds, not a fixed placeholder.
3842 assert!(
3843 serial.abs_diff(now_unix()) < 60,
3844 "SERIAL should be about now, got {serial}"
3845 );
3846 }
3847
3848 /// A NODATA — the name exists but we hold no address of the queried family, which is what an
3849 /// AAAA query for a peer becomes with the IPv6 gate off — is negative too, and takes the SOA.
3850 #[test]
3851 fn nodata_aaaa_for_known_peer_carries_the_soa() {
3852 let view = view_with_peer();
3853 assert!(!view.enable_ipv6, "default gate is off");
3854 let buf = build_query(0x2222, &["host", "user", "ts", "net"], 28, 1);
3855
3856 let resp = answer(&view, &buf).expect("answers");
3857 let (_, rcode, ancount) = parse_header(&resp);
3858 assert_eq!(rcode, 0, "NoError (NODATA)");
3859 assert_eq!(ancount, 0);
3860 let (zone, ttl, _, minimum) = parse_soa(&resp).expect("an SOA in the authority section");
3861 assert_eq!(zone, "user.ts.net");
3862 assert_eq!((ttl, minimum), (10, 10));
3863 }
3864
3865 /// A reverse query for an unmatched IP in the tailnet CGNAT range is authoritatively absent, so
3866 /// it carries the SOA of the reverse zone that covers it — the same per-/16 `in-addr.arpa`
3867 /// chunk real tailscaled advertises, not the search domain.
3868 #[test]
3869 fn cgnat_reverse_miss_carries_the_reverse_zone_soa() {
3870 let view = view_with_peer();
3871 // Reverse name for an unclaimed 100.64.0.0/10 address, least-significant octet first.
3872 let buf = build_query(0x3333, &["9", "0", "64", "100", "in-addr", "arpa"], 12, 1);
3873
3874 let resp = answer(&view, &buf).expect("answers");
3875 let (_, rcode, ancount) = parse_header(&resp);
3876 assert_eq!(rcode, 3, "NXDOMAIN");
3877 assert_eq!(ancount, 0);
3878 let (zone, ttl, _, minimum) = parse_soa(&resp).expect("an SOA in the authority section");
3879 assert_eq!(zone, "64.100.in-addr.arpa", "the CGNAT reverse zone");
3880 assert_eq!((ttl, minimum), (10, 10));
3881 }
3882
3883 /// The exotic-qtype path re-applies the CGNAT reverse guard, and its NXDOMAIN is just as
3884 /// authoritative — so it carries the same reverse-zone SOA the PTR arm does.
3885 #[test]
3886 fn exotic_qtype_cgnat_reverse_nxdomain_carries_the_soa() {
3887 let view = view_with_peer();
3888 // TXT (16) for a CGNAT reverse name.
3889 let buf = build_query(0x4444, &["9", "0", "64", "100", "in-addr", "arpa"], 16, 1);
3890
3891 let resp = answer(&view, &buf).expect("answers");
3892 assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
3893 let (zone, ..) = parse_soa(&resp).expect("an SOA in the authority section");
3894 assert_eq!(zone, "64.100.in-addr.arpa");
3895 }
3896
3897 /// A negative split-DNS route (a route with no resolvers) is Go's `localDomains` verbatim: the
3898 /// NXDOMAIN it produces is authoritative and names the route's own suffix as its zone.
3899 #[test]
3900 fn negative_route_nxdomain_carries_the_route_zone_soa() {
3901 let mut routes = std::collections::BTreeMap::new();
3902 routes.insert("corp.example".to_string(), vec![]);
3903 let view = view_with_routes(routes, vec![], vec![]);
3904 let buf = build_query(0x5555, &["intranet", "corp", "example"], 1, 1);
3905
3906 let resp = answer(&view, &buf).expect("answers");
3907 assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
3908 let (zone, ttl, _, minimum) = parse_soa(&resp).expect("an SOA in the authority section");
3909 assert_eq!(zone, "corp.example");
3910 assert_eq!((ttl, minimum), (10, 10));
3911 }
3912
3913 /// Answers we are NOT authoritative for carry no SOA: a SERVFAIL is a soft failure with nothing
3914 /// to cache, and an `ip6.arpa` NXDOMAIN is this fork's blanket anti-leak refusal, not a claim to
3915 /// serve the IPv6 reverse tree.
3916 #[test]
3917 fn non_authoritative_negative_answers_carry_no_soa() {
3918 let view = view_with_peer();
3919
3920 // Off-tailnet name, no upstream configured => SERVFAIL.
3921 let servfail =
3922 answer(&view, &build_query(0x6, &["example", "com"], 1, 1)).expect("answers");
3923 assert_eq!(parse_header(&servfail).1, 2, "ServFail");
3924 assert_eq!(nscount(&servfail), 0, "SERVFAIL carries no SOA");
3925
3926 // An ip6.arpa reverse name. The exact nibble labels do not matter to the guard.
3927 let mut labels: Vec<&str> = vec!["1"; 32];
3928 labels.push("ip6");
3929 labels.push("arpa");
3930 let ip6 = answer(&view, &build_query(0x7, &labels, 12, 1)).expect("answers");
3931 assert_eq!(parse_header(&ip6).1, 3, "NXDOMAIN");
3932 assert_eq!(nscount(&ip6), 0, "ip6.arpa NXDOMAIN carries no SOA");
3933
3934 // MagicDNS off => REFUSED, which asserts nothing about the name.
3935 let mut off = view_with_peer();
3936 off.cfg.magic_dns = false;
3937 let refused = answer(
3938 &off,
3939 &build_query(0x8, &["host", "user", "ts", "net"], 1, 1),
3940 )
3941 .expect("answers");
3942 assert_eq!(parse_header(&refused).1, 5, "Refused");
3943 assert_eq!(nscount(&refused), 0, "REFUSED carries no SOA");
3944 }
3945
3946 /// A NODATA for a type we simply do not serve on a name we do (TXT on a tailnet name) carries
3947 /// no SOA: Go sets `SOAZone` on a no-data answer only for an A/AAAA/ALL question.
3948 #[test]
3949 fn nodata_for_an_unserved_qtype_carries_no_soa() {
3950 let view = view_with_peer();
3951 let resp = answer(
3952 &view,
3953 &build_query(0x9, &["host", "user", "ts", "net"], 16, 1),
3954 )
3955 .expect("answers");
3956 let (_, rcode, ancount) = parse_header(&resp);
3957 assert_eq!((rcode, ancount), (0, 0), "NODATA");
3958 assert_eq!(nscount(&resp), 0);
3959 }
3960
3961 /// A positive answer has an empty authority section and a 5-second TTL. The short TTL is the
3962 /// positive half of the same argument: the netmap is local and in-memory, so a re-query is
3963 /// nearly free, while a downstream cache would otherwise hide a node rename for the full TTL.
3964 #[test]
3965 fn positive_answer_has_ttl_5_and_no_authority_section() {
3966 let view = view_with_peer();
3967 let resp = answer(
3968 &view,
3969 &build_query(0xA, &["host", "user", "ts", "net"], 1, 1),
3970 )
3971 .expect("answers");
3972 let (_, rcode, ancount) = parse_header(&resp);
3973 assert_eq!((rcode, ancount), (0, 1), "one A record");
3974 assert_eq!(nscount(&resp), 0, "a positive answer claims no zone");
3975 // The single A record's tail is TTL, RDLENGTH, RDATA.
3976 let ttl_at = resp.len() - 10;
3977 let ttl = u32::from_be_bytes(resp[ttl_at..ttl_at + 4].try_into().unwrap());
3978 assert_eq!(ttl, 5, "positive TTL");
3979 }
3980
3981 /// An authoritative negative answer with its SOA attached must still fit the classic 512-byte
3982 /// UDP limit, so the client-limit check leaves TC clear on it for a client that advertised no
3983 /// EDNS buffer. (A client that advertises *less* than 512 is a different case and is marked —
3984 /// see `an_authoritative_answer_over_the_advertised_size_is_marked`.)
3985 #[test]
3986 fn nxdomain_with_soa_stays_within_the_client_udp_limit() {
3987 let view = view_with_peer();
3988 let long = "a".repeat(63);
3989 let buf = build_query(0xB, &[&long, "user", "ts", "net"], 1, 1);
3990
3991 let resp = answer(&view, &buf).expect("answers");
3992 assert_eq!(nscount(&resp), 1, "the SOA fits beside this question");
3993 assert!(resp.len() <= 512, "still one classic UDP datagram");
3994
3995 let marked = check_response_size_and_set_tc(&buf, resp.clone(), ClientTransport::Udp);
3996 assert_eq!(marked, resp, "nothing to mark: an authoritative reply fits");
3997 assert_eq!(
3998 u16::from_be_bytes([marked[2], marked[3]]) & 0x0200,
3999 0,
4000 "TC must stay clear"
4001 );
4002 }
4003
4004 /// When the zone is so long that its SOA no longer fits under the 512-byte cap, the SOA is
4005 /// dropped rather than the answer being truncated: the NXDOMAIN goes back complete, with an
4006 /// empty authority section, TC clear, and still within a client's UDP limit. Losing the SOA
4007 /// only means a resolver falls back to its own negative-cache policy.
4008 #[test]
4009 fn an_soa_that_will_not_fit_is_dropped_and_the_nxdomain_still_answers() {
4010 let long = "a".repeat(63);
4011 let zone = [long.as_str(), long.as_str(), long.as_str()].join(".");
4012 let mut view = view_with_peer();
4013 view.cfg.search_domains = vec![zone.clone()];
4014
4015 let buf = build_query(0xC, &["x", &long, &long, &long], 1, 1);
4016 let resp = answer(&view, &buf).expect("answers");
4017
4018 assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
4019 assert_eq!(nscount(&resp), 0, "the SOA did not fit and was dropped");
4020 assert!(resp.len() <= 512, "response stays within the UDP limit");
4021 let marked = check_response_size_and_set_tc(&buf, resp.clone(), ClientTransport::Udp);
4022 assert_eq!(
4023 u16::from_be_bytes([marked[2], marked[3]]) & 0x0200,
4024 0,
4025 "a dropped SOA must not set TC: the fork cannot serve the TCP retry it would ask for"
4026 );
4027 }
4028
4029 /// The zone is the *longest* authoritative suffix containing the name, so a name under a
4030 /// sub-zone gets the sub-zone's SOA rather than the shorter search domain's.
4031 #[test]
4032 fn the_longest_authoritative_zone_wins() {
4033 let mut routes = std::collections::BTreeMap::new();
4034 routes.insert("sub.user.ts.net".to_string(), vec![]);
4035 let mut view = view_with_routes(routes, vec![], vec![]);
4036 view.cfg.search_domains = vec!["user.ts.net".to_string()];
4037
4038 let buf = build_query(0xD, &["nope", "sub", "user", "ts", "net"], 1, 1);
4039 let resp = answer(&view, &buf).expect("answers");
4040 assert_eq!(parse_header(&resp).1, 3, "NXDOMAIN");
4041 let (zone, ..) = parse_soa(&resp).expect("an SOA in the authority section");
4042 assert_eq!(zone, "sub.user.ts.net");
4043 }
4044
4045 /// A name we resolved only by search-domain qualification (a short name like `host`) is not
4046 /// itself inside a zone we serve, so its negative answer names no zone — matching Go, whose
4047 /// `authoritativeZoneFor` is given the query name as asked.
4048 #[test]
4049 fn a_short_name_outside_every_zone_gets_no_soa() {
4050 let mut view = view_with_peer();
4051 view.enable_ipv6 = false;
4052 // `host` resolves to the peer via search-domain qualification, and with IPv6 off the AAAA
4053 // is a NODATA — but `host` sits under no zone we serve.
4054 let resp = answer(&view, &build_query(0xE, &["host"], 28, 1)).expect("answers");
4055 let (_, rcode, ancount) = parse_header(&resp);
4056 assert_eq!((rcode, ancount), (0, 0), "NODATA");
4057 assert_eq!(nscount(&resp), 0, "no zone contains a single-label name");
4058 }
4059}