ts_control/dns.rs
1use alloc::{collections::BTreeMap, string::String, vec::Vec};
2use core::net::{IpAddr, SocketAddr};
3
4/// A control-pushed static host record (Go `tailcfg.DNSConfig.ExtraRecords`). MagicDNS answers
5/// these alongside tailnet peer names. Only `A`/`AAAA` records are kept; other record types are
6/// dropped, since the responder only serves address records.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ExtraRecord {
9 /// The record name, canonicalized: lowercased, no trailing dot.
10 pub name: String,
11 /// The address bound to `name`. `V4` answers `A`; `V6` answers `AAAA`.
12 pub addr: IpAddr,
13}
14
15/// An upstream DNS resolver to forward non-overlay queries to (Go `tailcfg.DNSResolver`).
16///
17/// Only plaintext UDP resolvers (`IP:port`, default port 53) are supported today; encrypted
18/// transports (DoH/DoT) are parsed off the wire but dropped here as a documented TODO seam —
19/// adding them only requires extending [`from_serde`][DnsConfig::from_serde] and the magic_dns
20/// forwarder, not the wire format.
21#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22pub struct Resolver {
23 /// The transport/address of this resolver. Only [`ResolverTransport::Udp`] is supported.
24 pub transport: ResolverTransport,
25 /// Continue using this resolver even while an exit node is in use (Go `UseWithExitNode`).
26 ///
27 /// When an exit node is selected, recursive DNS is normally delegated to the exit node's
28 /// peerAPI DoH server; a resolver with this flag set is kept locally instead (e.g. a split-DNS
29 /// server reachable over the tailnet that the exit node can't see). See
30 /// [`DnsConfig::resolvers_with_exit_node`].
31 pub use_with_exit_node: bool,
32}
33
34/// The transport of a [`Resolver`]. Only plaintext UDP is forwarded today; encrypted transports are
35/// dropped at parse time (see `Resolver::from_serde`).
36#[derive(Debug, Clone, PartialEq, Eq, Hash)]
37pub enum ResolverTransport {
38 /// Classic plaintext DNS over UDP at this address.
39 Udp(SocketAddr),
40}
41
42impl Resolver {
43 /// Build a UDP resolver from the borrowed serde view, or `None` for an encrypted transport
44 /// (DoH/DoT/DoH-over-WireGuard) we do not yet forward to.
45 pub(crate) fn from_serde(r: &ts_control_serde::DnsResolver<'_>) -> Option<Self> {
46 match r.addr {
47 ts_control_serde::DnsResolverAddr::Plaintext(addr) => Some(Resolver {
48 transport: ResolverTransport::Udp(addr),
49 use_with_exit_node: r.use_with_exit_node,
50 }),
51 // TODO: support DoH/DoT/HttpWireguard upstreams. Until then they are dropped so we
52 // never silently treat an encrypted resolver as a plaintext one.
53 _ => None,
54 }
55 }
56
57 /// The plaintext UDP socket address of this resolver.
58 pub fn udp_addr(&self) -> SocketAddr {
59 match self.transport {
60 ResolverTransport::Udp(addr) => addr,
61 }
62 }
63}
64
65/// Collect the supported (UDP) resolvers from a serde resolver list, dropping `None` entries and
66/// unsupported transports.
67fn resolvers_from_serde(list: &[Option<ts_control_serde::DnsResolver<'_>>]) -> Vec<Resolver> {
68 list.iter()
69 .filter_map(|r| r.as_ref())
70 .filter_map(Resolver::from_serde)
71 .collect()
72}
73
74/// Owned DNS configuration distilled from the control MapResponse for the MagicDNS responder.
75#[derive(Debug, Clone, Default, PartialEq, Eq)]
76pub struct DnsConfig {
77 /// MagicDNS enabled (Go `Proxied`). When false the responder serves nothing (fail closed).
78 pub magic_dns: bool,
79 /// Tailnet DNS suffix(es), lowercased, no trailing dot, e.g. "user.ts.net".
80 pub search_domains: Vec<String>,
81 /// Control-pushed static `A`/`AAAA` host records (Go `ExtraRecords`).
82 pub extra_records: Vec<ExtraRecord>,
83 /// Global upstream resolvers (Go `Resolvers`) used to recursively resolve non-overlay names
84 /// when no split-DNS route and no fallback resolver matches.
85 pub resolvers: Vec<Resolver>,
86 /// Split-DNS routes (Go `Routes`): suffix (canonicalized, no leading/trailing dot) -> the
87 /// upstreams that answer that suffix. An **empty** upstream list is a negative route: names
88 /// under that suffix are not resolved (Go keeps them on the built-in resolver, which for us
89 /// means fail-closed NXDOMAIN unless an overlay/extra record matches).
90 pub routes: BTreeMap<String, Vec<Resolver>>,
91 /// Fallback resolvers (Go `FallbackResolvers`) used for non-overlay names that match no route,
92 /// preferred over [`resolvers`][DnsConfig::resolvers].
93 pub fallback_resolvers: Vec<Resolver>,
94 /// DNS suffixes this node, **when acting as an exit-node DNS proxy**, must not answer (Go
95 /// `ExitNodeFilteredSet`). Entries are lowercased, no trailing dot. An entry starting with a
96 /// period is a suffix match (but `.a.b` does NOT match `a.b` — a real prefix label is
97 /// required); an entry without a leading period is an exact match. Matching is
98 /// case-insensitive. A filtered name is answered with `REFUSED`. See
99 /// [`DnsConfig::exit_node_filters`].
100 pub exit_node_filtered_set: Vec<String>,
101 /// DNS names control will assist provisioning TLS certs for (Go `tailcfg.DNSConfig.CertDomains`):
102 /// the cert-eligible FQDNs for this node, without trailing dots or `_acme-challenge.` prefix.
103 /// Surfaced verbatim (Go returns `slices.Clone(nm.DNS.CertDomains)`); empty when control sent none.
104 pub cert_domains: Vec<String>,
105}
106
107impl DnsConfig {
108 /// Build the owned config from the borrowed serde view parsed off the wire.
109 pub fn from_serde(c: &ts_control_serde::DnsConfig<'_>) -> Self {
110 DnsConfig {
111 magic_dns: c.magic_dns,
112 // Drop any search domain whose canonical suffix is empty (e.g. "" or ".").
113 // An empty suffix used in `ends_with` matching matches every name, which would
114 // silently turn the resolver into a match-all/block-all wildcard. Fail closed.
115 search_domains: c
116 .search_domains
117 .iter()
118 .map(|domain| canon(domain))
119 .filter(|domain| !domain.is_empty())
120 .collect(),
121 extra_records: c
122 .extra_records
123 .iter()
124 .filter_map(|rec| match rec {
125 ts_control_serde::DnsRecord::A { name, value } => Some(ExtraRecord {
126 name: canon(name),
127 addr: IpAddr::V4(*value),
128 }),
129 ts_control_serde::DnsRecord::AAAA { name, value } => Some(ExtraRecord {
130 name: canon(name),
131 addr: IpAddr::V6(*value),
132 }),
133 // The responder only serves address records; drop anything else.
134 ts_control_serde::DnsRecord::Other { .. } => None,
135 })
136 .collect(),
137 resolvers: resolvers_from_serde(&c.resolvers),
138 // Canonicalize route keys and drop any whose suffix is empty (e.g. "" or ".").
139 // An empty route key used in `ends_with` matching matches every name, which would
140 // silently capture all names as a route (match-all). Fail closed.
141 routes: c
142 .routes
143 .iter()
144 .map(|(suffix, upstreams)| {
145 let upstreams = upstreams
146 .as_deref()
147 .map(resolvers_from_serde)
148 .unwrap_or_default();
149 (canon(suffix), upstreams)
150 })
151 .filter(|(suffix, _)| !suffix.is_empty())
152 .collect(),
153 fallback_resolvers: resolvers_from_serde(&c.fallback_resolvers),
154 // Canonicalize each filtered-set entry by lowercasing only. We deliberately do NOT
155 // strip a leading period here: a leading period is semantically significant (it marks
156 // a suffix-match entry, per `exit_node_filters`). Trailing dots are stripped so a
157 // wire entry like "Example.com." matches our canonicalized query names.
158 exit_node_filtered_set: c
159 .exit_node_filtered_set
160 .iter()
161 .map(|e| e.strip_suffix('.').unwrap_or(e).to_ascii_lowercase())
162 .filter(|e| !e.is_empty() && e != ".")
163 .collect(),
164 // Carried verbatim (Go `slices.Clone(nm.DNS.CertDomains)` — no canonicalization). These
165 // are the names a `ListenTLS`/cert-issuance consumer requests, so they must match what
166 // control issued exactly.
167 cert_domains: c.cert_domains.iter().map(|d| d.to_string()).collect(),
168 }
169 }
170
171 /// Whether `name` (a canonical query name: lowercased, no trailing dot) is in this config's
172 /// [`exit_node_filtered_set`][DnsConfig::exit_node_filtered_set] and so must be `REFUSED` when
173 /// this node answers as an exit-node DNS proxy (Go `dnsConfigForNetmap`'s filtered-set check).
174 ///
175 /// An entry with a leading period is a suffix match requiring a real label before it (`.a.b`
176 /// matches `x.a.b` but not `a.b`); an entry without a leading period is an exact match.
177 /// Matching is case-insensitive (both sides are already lowercased).
178 pub fn exit_node_filters(&self, name: &str) -> bool {
179 self.exit_node_filtered_set.iter().any(|entry| {
180 if let Some(suffix) = entry.strip_prefix('.') {
181 // ".a.b" matches "x.a.b" (ends with ".a.b") but not "a.b" itself.
182 name.len() > suffix.len() + 1 && name.ends_with(suffix) && {
183 let boundary = name.len() - suffix.len() - 1;
184 name.as_bytes()[boundary] == b'.'
185 }
186 } else {
187 name == entry
188 }
189 })
190 }
191
192 /// The resolvers to keep when an exit node is active: those flagged
193 /// [`use_with_exit_node`][Resolver::use_with_exit_node]. When an exit node is selected,
194 /// recursive resolution is delegated to it, except for these explicitly-flagged resolvers (Go
195 /// keeps `UseWithExitNode` resolvers in the local config).
196 pub fn resolvers_with_exit_node(&self) -> impl Iterator<Item = &Resolver> {
197 self.resolvers.iter().filter(|r| r.use_with_exit_node)
198 }
199}
200
201/// Canonicalize a DNS name: strip a single trailing dot and ASCII-lowercase. ASCII-only to match
202/// the rest of the DNS name handling (`Name::to_canon`, the peer index) and avoid surprising
203/// Unicode case-folding on a wire-controlled string.
204fn canon(name: &str) -> String {
205 name.strip_suffix('.').unwrap_or(name).to_ascii_lowercase()
206}
207
208#[cfg(test)]
209mod tests {
210 use alloc::string::ToString;
211
212 use super::*;
213
214 #[test]
215 fn from_serde_strips_trailing_dot_and_lowercases() {
216 let serde_config = ts_control_serde::DnsConfig {
217 magic_dns: true,
218 search_domains: alloc::vec!["User.TS.net."],
219 ..Default::default()
220 };
221
222 let config = DnsConfig::from_serde(&serde_config);
223
224 assert!(config.magic_dns);
225 assert_eq!(
226 config.search_domains,
227 alloc::vec!["user.ts.net".to_string()]
228 );
229 }
230
231 #[test]
232 fn from_serde_magic_dns_false_is_preserved() {
233 let serde_config = ts_control_serde::DnsConfig::default();
234
235 let config = DnsConfig::from_serde(&serde_config);
236
237 assert!(!config.magic_dns);
238 assert!(config.search_domains.is_empty());
239 assert!(config.extra_records.is_empty());
240 }
241
242 #[test]
243 fn from_serde_carries_cert_domains_verbatim() {
244 // Go returns `slices.Clone(nm.DNS.CertDomains)` — verbatim, no canonicalization.
245 let serde_config = ts_control_serde::DnsConfig {
246 cert_domains: alloc::vec!["host.tail0123.ts.net", "other.tail0123.ts.net"],
247 ..Default::default()
248 };
249
250 let config = DnsConfig::from_serde(&serde_config);
251
252 assert_eq!(
253 config.cert_domains,
254 alloc::vec![
255 "host.tail0123.ts.net".to_string(),
256 "other.tail0123.ts.net".to_string()
257 ]
258 );
259 }
260
261 #[test]
262 fn from_serde_cert_domains_empty_when_absent() {
263 let config = DnsConfig::from_serde(&ts_control_serde::DnsConfig::default());
264 assert!(config.cert_domains.is_empty());
265 }
266
267 #[test]
268 fn from_serde_keeps_a_and_aaaa_extra_records_drops_other() {
269 use core::net::{Ipv4Addr, Ipv6Addr};
270
271 let serde_config = ts_control_serde::DnsConfig {
272 magic_dns: true,
273 extra_records: alloc::vec![
274 ts_control_serde::DnsRecord::A {
275 name: "Foo.Example.com.",
276 value: Ipv4Addr::new(10, 0, 0, 1),
277 },
278 ts_control_serde::DnsRecord::AAAA {
279 name: "bar.example.com",
280 value: "fd00::5".parse::<Ipv6Addr>().unwrap(),
281 },
282 ts_control_serde::DnsRecord::Other {
283 name: "txt.example.com",
284 ty: "TXT",
285 value: "ignored",
286 },
287 ],
288 ..Default::default()
289 };
290
291 let config = DnsConfig::from_serde(&serde_config);
292
293 // Names are canonicalized (lowercased, trailing dot stripped); the TXT record is dropped.
294 assert_eq!(config.extra_records.len(), 2);
295 assert_eq!(config.extra_records[0].name, "foo.example.com".to_string());
296 assert_eq!(
297 config.extra_records[0].addr,
298 core::net::IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))
299 );
300 assert_eq!(config.extra_records[1].name, "bar.example.com".to_string());
301 assert_eq!(
302 config.extra_records[1].addr,
303 "fd00::5".parse::<core::net::IpAddr>().unwrap()
304 );
305 }
306
307 #[test]
308 fn from_serde_drops_empty_route_keys_and_keeps_normal_suffix() {
309 let mut routes = BTreeMap::new();
310 // Both "" and "." canonicalize to "" and must be dropped so they never become a
311 // match-all wildcard in `ends_with` route matching.
312 routes.insert("", None);
313 routes.insert(".", None);
314 routes.insert("corp.ts.net", None);
315
316 let serde_config = ts_control_serde::DnsConfig {
317 magic_dns: true,
318 routes,
319 ..Default::default()
320 };
321
322 let config = DnsConfig::from_serde(&serde_config);
323
324 assert!(!config.routes.contains_key(""));
325 assert!(config.routes.contains_key("corp.ts.net"));
326 assert_eq!(config.routes.len(), 1);
327 }
328
329 #[test]
330 fn from_serde_drops_empty_search_domains_and_keeps_normal_suffix() {
331 let serde_config = ts_control_serde::DnsConfig {
332 magic_dns: true,
333 // "" and "." both canonicalize to "" and must be dropped; "corp.ts.net" survives.
334 search_domains: alloc::vec!["", ".", "corp.ts.net"],
335 ..Default::default()
336 };
337
338 let config = DnsConfig::from_serde(&serde_config);
339
340 assert_eq!(
341 config.search_domains,
342 alloc::vec!["corp.ts.net".to_string()]
343 );
344 }
345
346 #[test]
347 fn exit_node_filters_leading_period_is_suffix_match_requiring_a_label() {
348 let serde_config = ts_control_serde::DnsConfig {
349 magic_dns: true,
350 // A leading period marks a suffix match: ".a.b" must match "x.a.b" but NOT "a.b".
351 exit_node_filtered_set: alloc::vec![".a.b"],
352 ..Default::default()
353 };
354
355 let config = DnsConfig::from_serde(&serde_config);
356
357 assert!(config.exit_node_filters("x.a.b"));
358 assert!(config.exit_node_filters("deep.x.a.b"));
359 // The suffix itself is NOT matched by a leading-period entry (a real label is required).
360 assert!(!config.exit_node_filters("a.b"));
361 // A name merely ending in the bare letters but without the dot boundary is not matched.
362 assert!(!config.exit_node_filters("xa.b"));
363 assert!(!config.exit_node_filters("other.b"));
364 }
365
366 #[test]
367 fn exit_node_filters_no_leading_period_is_exact_match() {
368 let serde_config = ts_control_serde::DnsConfig {
369 magic_dns: true,
370 exit_node_filtered_set: alloc::vec!["a.b"],
371 ..Default::default()
372 };
373
374 let config = DnsConfig::from_serde(&serde_config);
375
376 assert!(config.exit_node_filters("a.b"));
377 // An exact entry must not match a subdomain.
378 assert!(!config.exit_node_filters("x.a.b"));
379 assert!(!config.exit_node_filters("a.b.c"));
380 }
381
382 #[test]
383 fn exit_node_filters_is_case_insensitive_and_trailing_dot_insensitive() {
384 let serde_config = ts_control_serde::DnsConfig {
385 magic_dns: true,
386 // Wire entries may be mixed-case with a trailing dot; both are canonicalized.
387 exit_node_filtered_set: alloc::vec!["Example.COM.", ".Internal.Corp."],
388 ..Default::default()
389 };
390
391 let config = DnsConfig::from_serde(&serde_config);
392
393 // Query names are already lowercased/no-trailing-dot canonical form.
394 assert!(config.exit_node_filters("example.com"));
395 assert!(config.exit_node_filters("host.internal.corp"));
396 assert!(!config.exit_node_filters("internal.corp"));
397 }
398
399 #[test]
400 fn resolvers_with_exit_node_keeps_only_flagged() {
401 use core::net::Ipv4Addr;
402
403 let kept = ts_control_serde::DnsResolver {
404 addr: ts_control_serde::DnsResolverAddr::Plaintext(SocketAddr::from((
405 Ipv4Addr::new(100, 64, 0, 1),
406 53,
407 ))),
408 bootstrap_resolution: Vec::new(),
409 use_with_exit_node: true,
410 };
411 let dropped = ts_control_serde::DnsResolver {
412 addr: ts_control_serde::DnsResolverAddr::Plaintext(SocketAddr::from((
413 Ipv4Addr::new(8, 8, 8, 8),
414 53,
415 ))),
416 bootstrap_resolution: Vec::new(),
417 use_with_exit_node: false,
418 };
419
420 let serde_config = ts_control_serde::DnsConfig {
421 magic_dns: true,
422 resolvers: alloc::vec![Some(kept), Some(dropped)],
423 ..Default::default()
424 };
425
426 let config = DnsConfig::from_serde(&serde_config);
427
428 let surviving: Vec<_> = config.resolvers_with_exit_node().collect();
429 assert_eq!(surviving.len(), 1);
430 assert_eq!(
431 surviving[0].udp_addr(),
432 SocketAddr::from((Ipv4Addr::new(100, 64, 0, 1), 53))
433 );
434 }
435}