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