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