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