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}
102
103impl DnsConfig {
104 /// Build the owned config from the borrowed serde view parsed off the wire.
105 pub fn from_serde(c: &ts_control_serde::DnsConfig<'_>) -> Self {
106 DnsConfig {
107 magic_dns: c.magic_dns,
108 // Drop any search domain whose canonical suffix is empty (e.g. "" or ".").
109 // An empty suffix used in `ends_with` matching matches every name, which would
110 // silently turn the resolver into a match-all/block-all wildcard. Fail closed.
111 search_domains: c
112 .search_domains
113 .iter()
114 .map(|domain| canon(domain))
115 .filter(|domain| !domain.is_empty())
116 .collect(),
117 extra_records: c
118 .extra_records
119 .iter()
120 .filter_map(|rec| match rec {
121 ts_control_serde::DnsRecord::A { name, value } => Some(ExtraRecord {
122 name: canon(name),
123 addr: IpAddr::V4(*value),
124 }),
125 ts_control_serde::DnsRecord::AAAA { name, value } => Some(ExtraRecord {
126 name: canon(name),
127 addr: IpAddr::V6(*value),
128 }),
129 // The responder only serves address records; drop anything else.
130 ts_control_serde::DnsRecord::Other { .. } => None,
131 })
132 .collect(),
133 resolvers: resolvers_from_serde(&c.resolvers),
134 // Canonicalize route keys and drop any whose suffix is empty (e.g. "" or ".").
135 // An empty route key used in `ends_with` matching matches every name, which would
136 // silently capture all names as a route (match-all). Fail closed.
137 routes: c
138 .routes
139 .iter()
140 .map(|(suffix, upstreams)| {
141 let upstreams = upstreams
142 .as_deref()
143 .map(resolvers_from_serde)
144 .unwrap_or_default();
145 (canon(suffix), upstreams)
146 })
147 .filter(|(suffix, _)| !suffix.is_empty())
148 .collect(),
149 fallback_resolvers: resolvers_from_serde(&c.fallback_resolvers),
150 // Canonicalize each filtered-set entry by lowercasing only. We deliberately do NOT
151 // strip a leading period here: a leading period is semantically significant (it marks
152 // a suffix-match entry, per `exit_node_filters`). Trailing dots are stripped so a
153 // wire entry like "Example.com." matches our canonicalized query names.
154 exit_node_filtered_set: c
155 .exit_node_filtered_set
156 .iter()
157 .map(|e| e.strip_suffix('.').unwrap_or(e).to_ascii_lowercase())
158 .filter(|e| !e.is_empty() && e != ".")
159 .collect(),
160 }
161 }
162
163 /// Whether `name` (a canonical query name: lowercased, no trailing dot) is in this config's
164 /// [`exit_node_filtered_set`][DnsConfig::exit_node_filtered_set] and so must be `REFUSED` when
165 /// this node answers as an exit-node DNS proxy (Go `dnsConfigForNetmap`'s filtered-set check).
166 ///
167 /// An entry with a leading period is a suffix match requiring a real label before it (`.a.b`
168 /// matches `x.a.b` but not `a.b`); an entry without a leading period is an exact match.
169 /// Matching is case-insensitive (both sides are already lowercased).
170 pub fn exit_node_filters(&self, name: &str) -> bool {
171 self.exit_node_filtered_set.iter().any(|entry| {
172 if let Some(suffix) = entry.strip_prefix('.') {
173 // ".a.b" matches "x.a.b" (ends with ".a.b") but not "a.b" itself.
174 name.len() > suffix.len() + 1 && name.ends_with(suffix) && {
175 let boundary = name.len() - suffix.len() - 1;
176 name.as_bytes()[boundary] == b'.'
177 }
178 } else {
179 name == entry
180 }
181 })
182 }
183
184 /// The resolvers to keep when an exit node is active: those flagged
185 /// [`use_with_exit_node`][Resolver::use_with_exit_node]. When an exit node is selected,
186 /// recursive resolution is delegated to it, except for these explicitly-flagged resolvers (Go
187 /// keeps `UseWithExitNode` resolvers in the local config).
188 pub fn resolvers_with_exit_node(&self) -> impl Iterator<Item = &Resolver> {
189 self.resolvers.iter().filter(|r| r.use_with_exit_node)
190 }
191}
192
193/// Canonicalize a DNS name: strip a single trailing dot and ASCII-lowercase. ASCII-only to match
194/// the rest of the DNS name handling (`Name::to_canon`, the peer index) and avoid surprising
195/// Unicode case-folding on a wire-controlled string.
196fn canon(name: &str) -> String {
197 name.strip_suffix('.').unwrap_or(name).to_ascii_lowercase()
198}
199
200#[cfg(test)]
201mod tests {
202 use alloc::string::ToString;
203
204 use super::*;
205
206 #[test]
207 fn from_serde_strips_trailing_dot_and_lowercases() {
208 let serde_config = ts_control_serde::DnsConfig {
209 magic_dns: true,
210 search_domains: alloc::vec!["User.TS.net."],
211 ..Default::default()
212 };
213
214 let config = DnsConfig::from_serde(&serde_config);
215
216 assert!(config.magic_dns);
217 assert_eq!(
218 config.search_domains,
219 alloc::vec!["user.ts.net".to_string()]
220 );
221 }
222
223 #[test]
224 fn from_serde_magic_dns_false_is_preserved() {
225 let serde_config = ts_control_serde::DnsConfig::default();
226
227 let config = DnsConfig::from_serde(&serde_config);
228
229 assert!(!config.magic_dns);
230 assert!(config.search_domains.is_empty());
231 assert!(config.extra_records.is_empty());
232 }
233
234 #[test]
235 fn from_serde_keeps_a_and_aaaa_extra_records_drops_other() {
236 use core::net::{Ipv4Addr, Ipv6Addr};
237
238 let serde_config = ts_control_serde::DnsConfig {
239 magic_dns: true,
240 extra_records: alloc::vec![
241 ts_control_serde::DnsRecord::A {
242 name: "Foo.Example.com.",
243 value: Ipv4Addr::new(10, 0, 0, 1),
244 },
245 ts_control_serde::DnsRecord::AAAA {
246 name: "bar.example.com",
247 value: "fd00::5".parse::<Ipv6Addr>().unwrap(),
248 },
249 ts_control_serde::DnsRecord::Other {
250 name: "txt.example.com",
251 ty: "TXT",
252 value: "ignored",
253 },
254 ],
255 ..Default::default()
256 };
257
258 let config = DnsConfig::from_serde(&serde_config);
259
260 // Names are canonicalized (lowercased, trailing dot stripped); the TXT record is dropped.
261 assert_eq!(config.extra_records.len(), 2);
262 assert_eq!(config.extra_records[0].name, "foo.example.com".to_string());
263 assert_eq!(
264 config.extra_records[0].addr,
265 core::net::IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))
266 );
267 assert_eq!(config.extra_records[1].name, "bar.example.com".to_string());
268 assert_eq!(
269 config.extra_records[1].addr,
270 "fd00::5".parse::<core::net::IpAddr>().unwrap()
271 );
272 }
273
274 #[test]
275 fn from_serde_drops_empty_route_keys_and_keeps_normal_suffix() {
276 let mut routes = BTreeMap::new();
277 // Both "" and "." canonicalize to "" and must be dropped so they never become a
278 // match-all wildcard in `ends_with` route matching.
279 routes.insert("", None);
280 routes.insert(".", None);
281 routes.insert("corp.ts.net", None);
282
283 let serde_config = ts_control_serde::DnsConfig {
284 magic_dns: true,
285 routes,
286 ..Default::default()
287 };
288
289 let config = DnsConfig::from_serde(&serde_config);
290
291 assert!(!config.routes.contains_key(""));
292 assert!(config.routes.contains_key("corp.ts.net"));
293 assert_eq!(config.routes.len(), 1);
294 }
295
296 #[test]
297 fn from_serde_drops_empty_search_domains_and_keeps_normal_suffix() {
298 let serde_config = ts_control_serde::DnsConfig {
299 magic_dns: true,
300 // "" and "." both canonicalize to "" and must be dropped; "corp.ts.net" survives.
301 search_domains: alloc::vec!["", ".", "corp.ts.net"],
302 ..Default::default()
303 };
304
305 let config = DnsConfig::from_serde(&serde_config);
306
307 assert_eq!(
308 config.search_domains,
309 alloc::vec!["corp.ts.net".to_string()]
310 );
311 }
312
313 #[test]
314 fn exit_node_filters_leading_period_is_suffix_match_requiring_a_label() {
315 let serde_config = ts_control_serde::DnsConfig {
316 magic_dns: true,
317 // A leading period marks a suffix match: ".a.b" must match "x.a.b" but NOT "a.b".
318 exit_node_filtered_set: alloc::vec![".a.b"],
319 ..Default::default()
320 };
321
322 let config = DnsConfig::from_serde(&serde_config);
323
324 assert!(config.exit_node_filters("x.a.b"));
325 assert!(config.exit_node_filters("deep.x.a.b"));
326 // The suffix itself is NOT matched by a leading-period entry (a real label is required).
327 assert!(!config.exit_node_filters("a.b"));
328 // A name merely ending in the bare letters but without the dot boundary is not matched.
329 assert!(!config.exit_node_filters("xa.b"));
330 assert!(!config.exit_node_filters("other.b"));
331 }
332
333 #[test]
334 fn exit_node_filters_no_leading_period_is_exact_match() {
335 let serde_config = ts_control_serde::DnsConfig {
336 magic_dns: true,
337 exit_node_filtered_set: alloc::vec!["a.b"],
338 ..Default::default()
339 };
340
341 let config = DnsConfig::from_serde(&serde_config);
342
343 assert!(config.exit_node_filters("a.b"));
344 // An exact entry must not match a subdomain.
345 assert!(!config.exit_node_filters("x.a.b"));
346 assert!(!config.exit_node_filters("a.b.c"));
347 }
348
349 #[test]
350 fn exit_node_filters_is_case_insensitive_and_trailing_dot_insensitive() {
351 let serde_config = ts_control_serde::DnsConfig {
352 magic_dns: true,
353 // Wire entries may be mixed-case with a trailing dot; both are canonicalized.
354 exit_node_filtered_set: alloc::vec!["Example.COM.", ".Internal.Corp."],
355 ..Default::default()
356 };
357
358 let config = DnsConfig::from_serde(&serde_config);
359
360 // Query names are already lowercased/no-trailing-dot canonical form.
361 assert!(config.exit_node_filters("example.com"));
362 assert!(config.exit_node_filters("host.internal.corp"));
363 assert!(!config.exit_node_filters("internal.corp"));
364 }
365
366 #[test]
367 fn resolvers_with_exit_node_keeps_only_flagged() {
368 use core::net::Ipv4Addr;
369
370 let kept = ts_control_serde::DnsResolver {
371 addr: ts_control_serde::DnsResolverAddr::Plaintext(SocketAddr::from((
372 Ipv4Addr::new(100, 64, 0, 1),
373 53,
374 ))),
375 bootstrap_resolution: Vec::new(),
376 use_with_exit_node: true,
377 };
378 let dropped = ts_control_serde::DnsResolver {
379 addr: ts_control_serde::DnsResolverAddr::Plaintext(SocketAddr::from((
380 Ipv4Addr::new(8, 8, 8, 8),
381 53,
382 ))),
383 bootstrap_resolution: Vec::new(),
384 use_with_exit_node: false,
385 };
386
387 let serde_config = ts_control_serde::DnsConfig {
388 magic_dns: true,
389 resolvers: alloc::vec![Some(kept), Some(dropped)],
390 ..Default::default()
391 };
392
393 let config = DnsConfig::from_serde(&serde_config);
394
395 let surviving: Vec<_> = config.resolvers_with_exit_node().collect();
396 assert_eq!(surviving.len(), 1);
397 assert_eq!(
398 surviving[0].udp_addr(),
399 SocketAddr::from((Ipv4Addr::new(100, 64, 0, 1), 53))
400 );
401 }
402}