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}
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}