Skip to main content

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}