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