geiserx_ts_control 0.28.3

tailscale control client
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
use alloc::{collections::BTreeMap, string::String, vec::Vec};
use core::net::{IpAddr, SocketAddr};

/// A control-pushed static host record (Go `tailcfg.DNSConfig.ExtraRecords`). MagicDNS answers
/// these alongside tailnet peer names. Only `A`/`AAAA` records are kept; other record types are
/// dropped, since the responder only serves address records.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtraRecord {
    /// The record name, canonicalized: lowercased, no trailing dot.
    pub name: String,
    /// The address bound to `name`. `V4` answers `A`; `V6` answers `AAAA`.
    pub addr: IpAddr,
}

/// An upstream DNS resolver to forward non-overlay queries to (Go `tailcfg.DNSResolver`).
///
/// Only plaintext UDP resolvers (`IP:port`, default port 53) are supported today; encrypted
/// transports (DoH/DoT) are parsed off the wire but dropped here as a documented TODO seam —
/// adding them only requires extending [`from_serde`][DnsConfig::from_serde] and the magic_dns
/// forwarder, not the wire format.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Resolver {
    /// The transport/address of this resolver. Only [`ResolverTransport::Udp`] is supported.
    pub transport: ResolverTransport,
    /// Continue using this resolver even while an exit node is in use (Go `UseWithExitNode`).
    ///
    /// When an exit node is selected, recursive DNS is normally delegated to the exit node's
    /// peerAPI DoH server; a resolver with this flag set is kept locally instead (e.g. a split-DNS
    /// server reachable over the tailnet that the exit node can't see). See
    /// [`DnsConfig::resolvers_with_exit_node`].
    pub use_with_exit_node: bool,
}

/// The transport of a [`Resolver`]. Only plaintext UDP is forwarded today; encrypted transports are
/// dropped at parse time (see `Resolver::from_serde`).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ResolverTransport {
    /// Classic plaintext DNS over UDP at this address.
    Udp(SocketAddr),
}

impl Resolver {
    /// Build a UDP resolver from the borrowed serde view, or `None` for an encrypted transport
    /// (DoH/DoT/DoH-over-WireGuard) we do not yet forward to.
    pub(crate) fn from_serde(r: &ts_control_serde::DnsResolver<'_>) -> Option<Self> {
        match r.addr {
            ts_control_serde::DnsResolverAddr::Plaintext(addr) => Some(Resolver {
                transport: ResolverTransport::Udp(addr),
                use_with_exit_node: r.use_with_exit_node,
            }),
            // TODO: support DoH/DoT/HttpWireguard upstreams. Until then they are dropped so we
            // never silently treat an encrypted resolver as a plaintext one.
            _ => None,
        }
    }

    /// The plaintext UDP socket address of this resolver.
    pub fn udp_addr(&self) -> SocketAddr {
        match self.transport {
            ResolverTransport::Udp(addr) => addr,
        }
    }
}

/// Collect the supported (UDP) resolvers from a serde resolver list, dropping `None` entries and
/// unsupported transports.
fn resolvers_from_serde(list: &[Option<ts_control_serde::DnsResolver<'_>>]) -> Vec<Resolver> {
    list.iter()
        .filter_map(|r| r.as_ref())
        .filter_map(Resolver::from_serde)
        .collect()
}

/// Owned DNS configuration distilled from the control MapResponse for the MagicDNS responder.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DnsConfig {
    /// MagicDNS enabled (Go `Proxied`). When false the responder serves nothing (fail closed).
    pub magic_dns: bool,
    /// Tailnet DNS suffix(es), lowercased, no trailing dot, e.g. "user.ts.net".
    pub search_domains: Vec<String>,
    /// Control-pushed static `A`/`AAAA` host records (Go `ExtraRecords`).
    pub extra_records: Vec<ExtraRecord>,
    /// Global upstream resolvers (Go `Resolvers`) used to recursively resolve non-overlay names
    /// when no split-DNS route and no fallback resolver matches.
    pub resolvers: Vec<Resolver>,
    /// Split-DNS routes (Go `Routes`): suffix (canonicalized, no leading/trailing dot) -> the
    /// upstreams that answer that suffix. An **empty** upstream list is a negative route: names
    /// under that suffix are not resolved (Go keeps them on the built-in resolver, which for us
    /// means fail-closed NXDOMAIN unless an overlay/extra record matches).
    pub routes: BTreeMap<String, Vec<Resolver>>,
    /// Fallback resolvers (Go `FallbackResolvers`) used for non-overlay names that match no route,
    /// preferred over [`resolvers`][DnsConfig::resolvers].
    pub fallback_resolvers: Vec<Resolver>,
    /// DNS suffixes this node, **when acting as an exit-node DNS proxy**, must not answer (Go
    /// `ExitNodeFilteredSet`). Entries are lowercased, no trailing dot. An entry starting with a
    /// period is a suffix match (but `.a.b` does NOT match `a.b` — a real prefix label is
    /// required); an entry without a leading period is an exact match. Matching is
    /// case-insensitive. A filtered name is answered with `REFUSED`. See
    /// [`DnsConfig::exit_node_filters`].
    pub exit_node_filtered_set: Vec<String>,
    /// DNS names control will assist provisioning TLS certs for (Go `tailcfg.DNSConfig.CertDomains`):
    /// the cert-eligible FQDNs for this node, without trailing dots or `_acme-challenge.` prefix.
    /// Surfaced verbatim (Go returns `slices.Clone(nm.DNS.CertDomains)`); empty when control sent none.
    pub cert_domains: Vec<String>,
}

impl DnsConfig {
    /// Build the owned config from the borrowed serde view parsed off the wire.
    pub fn from_serde(c: &ts_control_serde::DnsConfig<'_>) -> Self {
        DnsConfig {
            magic_dns: c.magic_dns,
            // Drop any search domain whose canonical suffix is empty (e.g. "" or ".").
            // An empty suffix used in `ends_with` matching matches every name, which would
            // silently turn the resolver into a match-all/block-all wildcard. Fail closed.
            search_domains: c
                .search_domains
                .iter()
                .map(|domain| canon(domain))
                .filter(|domain| !domain.is_empty())
                .collect(),
            extra_records: c
                .extra_records
                .iter()
                .filter_map(|rec| match rec {
                    ts_control_serde::DnsRecord::A { name, value } => Some(ExtraRecord {
                        name: canon(name),
                        addr: IpAddr::V4(*value),
                    }),
                    ts_control_serde::DnsRecord::AAAA { name, value } => Some(ExtraRecord {
                        name: canon(name),
                        addr: IpAddr::V6(*value),
                    }),
                    // The responder only serves address records; drop anything else.
                    ts_control_serde::DnsRecord::Other { .. } => None,
                })
                .collect(),
            resolvers: resolvers_from_serde(&c.resolvers),
            // Canonicalize route keys and drop any whose suffix is empty (e.g. "" or ".").
            // An empty route key used in `ends_with` matching matches every name, which would
            // silently capture all names as a route (match-all). Fail closed.
            routes: c
                .routes
                .iter()
                .map(|(suffix, upstreams)| {
                    let upstreams = upstreams
                        .as_deref()
                        .map(resolvers_from_serde)
                        .unwrap_or_default();
                    (canon(suffix), upstreams)
                })
                .filter(|(suffix, _)| !suffix.is_empty())
                .collect(),
            fallback_resolvers: resolvers_from_serde(&c.fallback_resolvers),
            // Canonicalize each filtered-set entry by lowercasing only. We deliberately do NOT
            // strip a leading period here: a leading period is semantically significant (it marks
            // a suffix-match entry, per `exit_node_filters`). Trailing dots are stripped so a
            // wire entry like "Example.com." matches our canonicalized query names.
            exit_node_filtered_set: c
                .exit_node_filtered_set
                .iter()
                .map(|e| e.strip_suffix('.').unwrap_or(e).to_ascii_lowercase())
                .filter(|e| !e.is_empty() && e != ".")
                .collect(),
            // Carried verbatim (Go `slices.Clone(nm.DNS.CertDomains)` — no canonicalization). These
            // are the names a `ListenTLS`/cert-issuance consumer requests, so they must match what
            // control issued exactly.
            cert_domains: c.cert_domains.iter().map(|d| d.to_string()).collect(),
        }
    }

    /// Whether `name` (a canonical query name: lowercased, no trailing dot) is in this config's
    /// [`exit_node_filtered_set`][DnsConfig::exit_node_filtered_set] and so must be `REFUSED` when
    /// this node answers as an exit-node DNS proxy (Go `dnsConfigForNetmap`'s filtered-set check).
    ///
    /// An entry with a leading period is a suffix match requiring a real label before it (`.a.b`
    /// matches `x.a.b` but not `a.b`); an entry without a leading period is an exact match.
    /// Matching is case-insensitive (both sides are already lowercased).
    pub fn exit_node_filters(&self, name: &str) -> bool {
        self.exit_node_filtered_set.iter().any(|entry| {
            if let Some(suffix) = entry.strip_prefix('.') {
                // ".a.b" matches "x.a.b" (ends with ".a.b") but not "a.b" itself.
                name.len() > suffix.len() + 1 && name.ends_with(suffix) && {
                    let boundary = name.len() - suffix.len() - 1;
                    name.as_bytes()[boundary] == b'.'
                }
            } else {
                name == entry
            }
        })
    }

    /// The resolvers to keep when an exit node is active: those flagged
    /// [`use_with_exit_node`][Resolver::use_with_exit_node]. When an exit node is selected,
    /// recursive resolution is delegated to it, except for these explicitly-flagged resolvers (Go
    /// keeps `UseWithExitNode` resolvers in the local config).
    pub fn resolvers_with_exit_node(&self) -> impl Iterator<Item = &Resolver> {
        self.resolvers.iter().filter(|r| r.use_with_exit_node)
    }
}

/// Canonicalize a DNS name: strip a single trailing dot and ASCII-lowercase. ASCII-only to match
/// the rest of the DNS name handling (`Name::to_canon`, the peer index) and avoid surprising
/// Unicode case-folding on a wire-controlled string.
fn canon(name: &str) -> String {
    name.strip_suffix('.').unwrap_or(name).to_ascii_lowercase()
}

#[cfg(test)]
mod tests {
    use alloc::string::ToString;

    use super::*;

    #[test]
    fn from_serde_strips_trailing_dot_and_lowercases() {
        let serde_config = ts_control_serde::DnsConfig {
            magic_dns: true,
            search_domains: alloc::vec!["User.TS.net."],
            ..Default::default()
        };

        let config = DnsConfig::from_serde(&serde_config);

        assert!(config.magic_dns);
        assert_eq!(
            config.search_domains,
            alloc::vec!["user.ts.net".to_string()]
        );
    }

    #[test]
    fn from_serde_magic_dns_false_is_preserved() {
        let serde_config = ts_control_serde::DnsConfig::default();

        let config = DnsConfig::from_serde(&serde_config);

        assert!(!config.magic_dns);
        assert!(config.search_domains.is_empty());
        assert!(config.extra_records.is_empty());
    }

    #[test]
    fn from_serde_carries_cert_domains_verbatim() {
        // Go returns `slices.Clone(nm.DNS.CertDomains)` — verbatim, no canonicalization.
        let serde_config = ts_control_serde::DnsConfig {
            cert_domains: alloc::vec!["host.tail0123.ts.net", "other.tail0123.ts.net"],
            ..Default::default()
        };

        let config = DnsConfig::from_serde(&serde_config);

        assert_eq!(
            config.cert_domains,
            alloc::vec![
                "host.tail0123.ts.net".to_string(),
                "other.tail0123.ts.net".to_string()
            ]
        );
    }

    #[test]
    fn from_serde_cert_domains_empty_when_absent() {
        let config = DnsConfig::from_serde(&ts_control_serde::DnsConfig::default());
        assert!(config.cert_domains.is_empty());
    }

    #[test]
    fn from_serde_keeps_a_and_aaaa_extra_records_drops_other() {
        use core::net::{Ipv4Addr, Ipv6Addr};

        let serde_config = ts_control_serde::DnsConfig {
            magic_dns: true,
            extra_records: alloc::vec![
                ts_control_serde::DnsRecord::A {
                    name: "Foo.Example.com.",
                    value: Ipv4Addr::new(10, 0, 0, 1),
                },
                ts_control_serde::DnsRecord::AAAA {
                    name: "bar.example.com",
                    value: "fd00::5".parse::<Ipv6Addr>().unwrap(),
                },
                ts_control_serde::DnsRecord::Other {
                    name: "txt.example.com",
                    ty: "TXT",
                    value: "ignored",
                },
            ],
            ..Default::default()
        };

        let config = DnsConfig::from_serde(&serde_config);

        // Names are canonicalized (lowercased, trailing dot stripped); the TXT record is dropped.
        assert_eq!(config.extra_records.len(), 2);
        assert_eq!(config.extra_records[0].name, "foo.example.com".to_string());
        assert_eq!(
            config.extra_records[0].addr,
            core::net::IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))
        );
        assert_eq!(config.extra_records[1].name, "bar.example.com".to_string());
        assert_eq!(
            config.extra_records[1].addr,
            "fd00::5".parse::<core::net::IpAddr>().unwrap()
        );
    }

    #[test]
    fn from_serde_drops_empty_route_keys_and_keeps_normal_suffix() {
        let mut routes = BTreeMap::new();
        // Both "" and "." canonicalize to "" and must be dropped so they never become a
        // match-all wildcard in `ends_with` route matching.
        routes.insert("", None);
        routes.insert(".", None);
        routes.insert("corp.ts.net", None);

        let serde_config = ts_control_serde::DnsConfig {
            magic_dns: true,
            routes,
            ..Default::default()
        };

        let config = DnsConfig::from_serde(&serde_config);

        assert!(!config.routes.contains_key(""));
        assert!(config.routes.contains_key("corp.ts.net"));
        assert_eq!(config.routes.len(), 1);
    }

    #[test]
    fn from_serde_drops_empty_search_domains_and_keeps_normal_suffix() {
        let serde_config = ts_control_serde::DnsConfig {
            magic_dns: true,
            // "" and "." both canonicalize to "" and must be dropped; "corp.ts.net" survives.
            search_domains: alloc::vec!["", ".", "corp.ts.net"],
            ..Default::default()
        };

        let config = DnsConfig::from_serde(&serde_config);

        assert_eq!(
            config.search_domains,
            alloc::vec!["corp.ts.net".to_string()]
        );
    }

    #[test]
    fn exit_node_filters_leading_period_is_suffix_match_requiring_a_label() {
        let serde_config = ts_control_serde::DnsConfig {
            magic_dns: true,
            // A leading period marks a suffix match: ".a.b" must match "x.a.b" but NOT "a.b".
            exit_node_filtered_set: alloc::vec![".a.b"],
            ..Default::default()
        };

        let config = DnsConfig::from_serde(&serde_config);

        assert!(config.exit_node_filters("x.a.b"));
        assert!(config.exit_node_filters("deep.x.a.b"));
        // The suffix itself is NOT matched by a leading-period entry (a real label is required).
        assert!(!config.exit_node_filters("a.b"));
        // A name merely ending in the bare letters but without the dot boundary is not matched.
        assert!(!config.exit_node_filters("xa.b"));
        assert!(!config.exit_node_filters("other.b"));
    }

    #[test]
    fn exit_node_filters_no_leading_period_is_exact_match() {
        let serde_config = ts_control_serde::DnsConfig {
            magic_dns: true,
            exit_node_filtered_set: alloc::vec!["a.b"],
            ..Default::default()
        };

        let config = DnsConfig::from_serde(&serde_config);

        assert!(config.exit_node_filters("a.b"));
        // An exact entry must not match a subdomain.
        assert!(!config.exit_node_filters("x.a.b"));
        assert!(!config.exit_node_filters("a.b.c"));
    }

    #[test]
    fn exit_node_filters_is_case_insensitive_and_trailing_dot_insensitive() {
        let serde_config = ts_control_serde::DnsConfig {
            magic_dns: true,
            // Wire entries may be mixed-case with a trailing dot; both are canonicalized.
            exit_node_filtered_set: alloc::vec!["Example.COM.", ".Internal.Corp."],
            ..Default::default()
        };

        let config = DnsConfig::from_serde(&serde_config);

        // Query names are already lowercased/no-trailing-dot canonical form.
        assert!(config.exit_node_filters("example.com"));
        assert!(config.exit_node_filters("host.internal.corp"));
        assert!(!config.exit_node_filters("internal.corp"));
    }

    #[test]
    fn resolvers_with_exit_node_keeps_only_flagged() {
        use core::net::Ipv4Addr;

        let kept = ts_control_serde::DnsResolver {
            addr: ts_control_serde::DnsResolverAddr::Plaintext(SocketAddr::from((
                Ipv4Addr::new(100, 64, 0, 1),
                53,
            ))),
            bootstrap_resolution: Vec::new(),
            use_with_exit_node: true,
        };
        let dropped = ts_control_serde::DnsResolver {
            addr: ts_control_serde::DnsResolverAddr::Plaintext(SocketAddr::from((
                Ipv4Addr::new(8, 8, 8, 8),
                53,
            ))),
            bootstrap_resolution: Vec::new(),
            use_with_exit_node: false,
        };

        let serde_config = ts_control_serde::DnsConfig {
            magic_dns: true,
            resolvers: alloc::vec![Some(kept), Some(dropped)],
            ..Default::default()
        };

        let config = DnsConfig::from_serde(&serde_config);

        let surviving: Vec<_> = config.resolvers_with_exit_node().collect();
        assert_eq!(surviving.len(), 1);
        assert_eq!(
            surviving[0].udp_addr(),
            SocketAddr::from((Ipv4Addr::new(100, 64, 0, 1), 53))
        );
    }
}