ts_control 0.4.0

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
436
437
438
439
440
441
442
443
444
445
446
447
448
449
use alloc::collections::BTreeMap;
use core::{
    fmt,
    net::{IpAddr, SocketAddr},
};

use chrono::{DateTime, Utc};
use ts_capabilityversion::CapabilityVersion;
use ts_keys::{DiscoPublicKey, MachinePublicKey, NodePublicKey};

const LAST_SEEN_FORMAT: &str = "%F %T %Z";

/// The unique id of a node.
pub type Id = i64;

/// The stable ID of a node.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct StableId(pub String);

/// Timestamps indicating when an offline node was last connected to the control plane. Only applies
/// to nodes that are offline (disconnected from the control plane).
///
/// If populated, most `control` timestamp values are only accurate to a resolution of ~10 minutes
/// for privacy reasons. See each variant for important information on comparison of timestamp
/// values and which clock is used to generate a timestamp.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub struct NodeLastSeen {
    /// The last time the node was connected to the control plane, as reported by the control plane
    /// itself. In some cases, the control plane does not report a "last seen" timestamp, just that
    /// a node is offline; in those cases, this field will be `None`.
    ///
    /// The timestamp may be rounded to the nearest 10-minute boundary before being reported to this
    /// device by the control plane; in other words, many `control` timestamps will be aligned to
    /// 10-minute boundaries. This is a privacy-preserving measure implemented by the control plane.
    /// Note that we have seen some non-rounded timestamps being reported from the control plane,
    /// although it's unclear if this is a bug or by design.
    ///
    /// Note that the timestamp value was generated by the control plane. The control plane's
    /// "clock" isn't synchronized with the local device's clock, so the timestamp value may be
    /// much more than 5 minutes in the past/future when compared to the `estimated` timestamp/local
    /// device clock. Do not directly compare `NodeLastSeen::control` timestamps with
    /// `NodeLastSeen::estimated` timestamps.
    pub control: Option<DateTime<Utc>>,
    /// The last time the node was connected to the control plane, as estimated by this device. The
    /// timestamp is roughly the time this device was notified the node was offline by the control
    /// plane, using the local device clock.
    ///
    /// Note that the timestamp value was generated by the local device clock. The control plane's
    /// "clock" isn't synchronized with the local device's clock, so the timestamp value may differ
    /// greatly from the `control` timestamp/control plane clock. Do not directly compare
    /// `NodeLastSeen::control` timestamps with `NodeLastSeen::estimated` timestamps.
    pub estimated: DateTime<Utc>,
}

impl Default for NodeLastSeen {
    fn default() -> Self {
        Self {
            control: None,
            // We intentionally don't fuzz the estimated timestamp value because the Go client code
            // doesn't either.
            // See: https://github.com/tailscale/tailscale/blob/ee0a03b140021541495b25bdb6642b589431758b/control/controlclient/map.go#L753-L764
            estimated: Utc::now(),
        }
    }
}

impl fmt::Display for NodeLastSeen {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "last seen: {} (by control: ",
            self.estimated.format(LAST_SEEN_FORMAT)
        )?;
        match self.control {
            None => write!(f, "unknown)"),
            Some(dt) => write!(f, "{})", dt.format(LAST_SEEN_FORMAT)),
        }
    }
}

impl NodeLastSeen {
    /// Construct a new `NodeLastSeen` with an optional "last seen by control" timestamp. The
    /// `estimated` timestamp is always set to the current UTC date/time.
    pub fn new(control: Option<DateTime<Utc>>) -> Self {
        Self {
            control,
            ..Default::default()
        }
    }
}

/// Whether a node is online (connected to the control plane) or offline (disconnected from the
/// control plane).
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash)]
pub enum NodeStatus {
    /// The node may be online or offline; the control plane hasn't informed us yet.
    #[default]
    Unknown,
    /// The node is online (connected to the control plane).
    Online,
    /// The node is offline (disconnected from the control plane).
    Offline(NodeLastSeen),
}

impl fmt::Display for NodeStatus {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            NodeStatus::Unknown => write!(f, "unknown"),
            NodeStatus::Online => write!(f, "online"),
            NodeStatus::Offline(nls) => write!(f, "offline, {nls}"),
        }
    }
}

impl NodeStatus {
    /// Construct a new `NodeStatus` from a combination of online and last-seen timestamp.
    ///
    /// The netmap messages we get from the control plane have multiple ways to indicate a peer is
    /// online and/or the timestamp the peer was last connected to the control plane. This method
    /// wraps the sometimes-painful logic of creating a `NodeStatus` from the various combinations
    /// of netmap field values.
    pub fn new(online: Option<bool>, last_seen: Option<DateTime<Utc>>) -> Self {
        match (online, last_seen) {
            (Some(true), None) => Self::Online,
            (Some(false), None) => Self::Offline(NodeLastSeen::new(None)),
            (Some(false), Some(dt)) | (None, Some(dt)) => {
                Self::Offline(NodeLastSeen::new(Some(dt)))
            }
            (None, None) => Self::Unknown,
            (online, last_seen) => {
                tracing::warn!(
                    ?online,
                    ?last_seen,
                    "unexpected combination of online/last_seen states"
                );
                Self::Unknown
            }
        }
    }
}

/// A node in a tailnet.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Node {
    /// The node's id.
    pub id: Id,
    /// The node's stable id.
    pub stable_id: StableId,
    /// The node's hostname.
    pub hostname: String,

    /// The node's capability version.
    pub capability_version: CapabilityVersion,

    /// Whether the node is connected to the control plane or not. If [`NodeStatus::Unknown`], the
    /// control plane hasn't told us yet.
    ///
    /// This field does _not_ indicate whether the node is reachable or visible from this device.
    pub status: NodeStatus,

    /// The tailnet this node belongs to.
    pub tailnet: Option<String>,

    /// The node capabilities assigned to this node.
    pub node_capabilities: BTreeMap<String, Vec<String>>,

    /// The tags assigned to this node.
    pub tags: Vec<String>,

    /// The address of the node in the tailnet.
    pub tailnet_address: TailnetAddress,

    /// The node's [`NodePublicKey`].
    pub node_key: NodePublicKey,
    /// The node key's expiration.
    pub node_key_expiry: Option<DateTime<Utc>>,

    /// The node's [`MachinePublicKey`], if known.
    pub machine_key: Option<MachinePublicKey>,
    /// The node's [`DiscoPublicKey`], if known.
    pub disco_key: Option<DiscoPublicKey>,
    /// The signature of the node's public key with the Tailnet Lock signing key, if Tailnet Lock
    /// is enabled and the signature is known.
    pub tailnet_lock_key_signature: Option<Vec<u8>>,

    /// The routes this node accepts traffic for.
    pub accepted_routes: Vec<ipnet::IpNet>,
    /// The underlay addresses this node is reachable on (`Endpoints` in Go).
    pub underlay_addresses: Vec<SocketAddr>,

    /// The DERP region for this node, if known.
    pub derp_region: Option<ts_derp::RegionId>,
}

impl Node {
    /// Apply the given partial update to this `Node`.
    ///
    /// # Panics
    /// If `update.id` does not match `self.id`. The caller must guarantee this method is only
    /// called with [`NodeUpdate`]s for this `Node`.
    #[tracing::instrument(skip_all, fields(id = self.stable_id.0, hostname = self.hostname))]
    pub fn apply_update(&mut self, update: &NodeUpdate) {
        assert_eq!(self.id, update.id, "node update ID != node ID");

        if update.status != NodeStatus::Unknown {
            tracing::debug!(old_status = %self.status, "peer {}", update.status);
            self.status = update.status;
        }

        if let Some(cap) = update.cap {
            tracing::debug!(old=?self.capability_version, new=?cap, "updating capability version");
            self.capability_version = cap;
        }

        if let Some(cap_map) = update.cap_map.as_ref() {
            tracing::debug!(old=?self.node_capabilities, new=?cap_map, "updating node capabilities");
            self.node_capabilities = cap_map.clone();
        }

        if let Some(derp_region) = update.derp_region {
            tracing::debug!(old=?self.derp_region, new=?derp_region, "updating derp home region");
            self.derp_region = Some(derp_region);
        }

        if let Some(disco_key) = update.disco_key {
            tracing::debug!(old=?self.disco_key, new=?disco_key, "updating disco key");
            self.disco_key = Some(disco_key);
        }

        if let Some(node_key) = update.node_key {
            tracing::debug!(old=?self.node_key, new=?node_key, "updating node key");
            self.node_key = node_key;
        }

        if let Some(node_key_expiry) = update.node_key_expiry {
            tracing::debug!(old=?self.node_key_expiry, new=?node_key_expiry, "updating node key expiry");
            self.node_key_expiry = Some(node_key_expiry);
        }

        if let Some(tl_sig) = &update.tailnet_lock_key_signature {
            tracing::debug!(old=?self.tailnet_lock_key_signature, new=?tl_sig, "updating tailnet lock key signature");
            self.tailnet_lock_key_signature = Some(tl_sig.clone());
        }

        if let Some(underlay_addresses) = &update.underlay_addresses {
            tracing::debug!(old=?self.underlay_addresses, new=?underlay_addresses, "updating underlay addresses");
            self.underlay_addresses = underlay_addresses.clone();
        }
    }

    /// The fully-qualified domain name of the node.
    ///
    /// This is a string of the form `$HOST.$TAILNET_DOMAIN.`. For tailnets controlled by
    /// Tailscale's control plane, this usually means `$HOST.tail1234.ts.net.`
    ///
    /// The `trailing_dot` parameter specifies whether to include the trailing dot in the
    /// fqdn. This is included by the definition of FQDN, and is the way the Go codebase
    /// formats this field, but the parameter is included to allow turning it off for use
    /// in contexts that expect it to be absent.
    pub fn fqdn(&self, trailing_dot: bool) -> String {
        let dot = if trailing_dot { "." } else { "" };
        match &self.tailnet {
            Some(tailnet) => format!("{}.{tailnet}{dot}", self.hostname),
            None => format!("{}{dot}", self.hostname),
        }
    }

    /// The fully-qualified domain name of the node, only returning `Some` if the tailnet
    /// component is present.
    ///
    /// See [`Node::fqdn`].
    pub fn fqdn_opt(&self, trailing_dot: bool) -> Option<String> {
        let dot = if trailing_dot { "." } else { "" };
        let tailnet = self.tailnet.as_deref()?;

        Some(format!("{}.{tailnet}{dot}", self.hostname))
    }

    /// Report whether this node matches the given `name`.
    ///
    /// `name` is checked for equality with both this node's bare hostname and its fqdn. A
    /// trailing `.` may be present.
    pub fn matches_name(&self, name: &str) -> bool {
        // This approach is taken to avoid allocating a buffer just for the sake of making this
        // comparison: try to chop `.tailnet.` off of the end of `name` and compare the
        // remainder to our hostname. If `.tailnet.` doesn't match `name`, we'll end up comparing
        // our hostname to `hostname.other_tailnet.`, which won't succeed. If `name` was just the
        // hostname, nothing will have been chopped, so the comparison will still be hostname-to-
        // hostname.

        let name = name.strip_suffix('.').unwrap_or(name);

        let name = if let Some(tailnet) = &self.tailnet {
            name.strip_suffix(tailnet.as_str())
                .and_then(|name| name.strip_suffix('.'))
                .unwrap_or(name)
        } else {
            name
        };

        name == self.hostname
    }
}

/// Addresses for a node within a tailnet.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TailnetAddress {
    /// The IPv4 address of the node in the tailnet.
    pub ipv4: ipnet::Ipv4Net,
    /// The IPv6 address of the node in the tailnet.
    pub ipv6: ipnet::Ipv6Net,
}

impl TailnetAddress {
    /// Report whether `addr` matches either address in this [`TailnetAddress`].
    pub fn contains(&self, addr: IpAddr) -> bool {
        match addr {
            IpAddr::V4(a) => self.ipv4.addr() == a,
            IpAddr::V6(a) => self.ipv6.addr() == a,
        }
    }
}

impl From<&ts_control_serde::Node<'_>> for Node {
    fn from(value: &ts_control_serde::Node) -> Self {
        let fqdn_without_trailing_dot = value.name.strip_suffix('.').unwrap_or(value.name);

        let (hostname, tailnet) = match fqdn_without_trailing_dot.split_once('.') {
            Some((hostname, tailnet)) => (hostname, Some(tailnet.to_owned())),
            None => (fqdn_without_trailing_dot, None),
        };

        Self {
            id: value.id,
            stable_id: StableId(value.stable_id.0.to_string()),
            hostname: hostname.to_owned(),

            capability_version: value.cap,

            status: NodeStatus::new(value.online, value.last_seen),
            tailnet,

            node_capabilities: value
                .cap_map
                .iter()
                .map(|(k, v)| (k.to_string(), v.into()))
                .collect(),
            tags: value
                .tags
                .as_ref()
                .map(|x| x.iter().map(|x| x.to_string()).collect())
                .unwrap_or_default(),

            tailnet_address: TailnetAddress {
                ipv4: value.addresses.0,
                ipv6: value.addresses.1,
            },
            node_key: value.key,
            node_key_expiry: value.key_expiry,
            machine_key: value.machine,
            disco_key: value.disco_key,
            tailnet_lock_key_signature: value.key_signature.as_ref().map(|s| Vec::<u8>::from(*s)),

            accepted_routes: value
                .allowed_ips
                .clone()
                .unwrap_or_else(|| vec![value.addresses.0.into(), value.addresses.1.into()]),
            underlay_addresses: value.endpoints.clone(),

            // legacy_derp_string is still in practical use as of 3/2026
            #[allow(deprecated)]
            derp_region: value
                .home_derp
                .or(value.legacy_derp_string)
                .or_else(|| value.host_info.net_info.as_ref()?.preferred_derp)
                .map(|x| ts_derp::RegionId(x.into())),
        }
    }
}

/// A partial update to a [`Node`] in a tailnet.
///
/// Devices should reject any `NodeUpdate` that it doesn't have a corresponding [`Node`] for. This
/// type combines multiple different update/patch-style fields from netmap messages into a single
/// type to simplify update handling for interested components, such as the peer tracker.
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
pub struct NodeUpdate {
    /// The node's id.
    pub id: Id,

    /// Whether this node is online or offline (with last-seen timestamp), according to the control
    /// plane. If [`NodeStatus::Unknown`], has not changed.
    pub status: NodeStatus,

    /// The DERP region for this node. If `None`, has not changed.
    pub derp_region: Option<ts_derp::RegionId>,

    /// The node's capability version. If `None`, has not changed.
    pub cap: Option<CapabilityVersion>,
    /// The node's capabilities (node caps, not peer caps). If `None`, has not changed.
    pub cap_map: Option<BTreeMap<String, Vec<String>>>,

    /// The node's [`NodePublicKey`]. If `None`, has not changed.
    pub node_key: Option<NodePublicKey>,
    /// The node key's expiration. If `None`, has not changed.
    pub node_key_expiry: Option<DateTime<Utc>>,

    /// The node's [`DiscoPublicKey`]. If `None`, has not changed.
    pub disco_key: Option<DiscoPublicKey>,

    /// The node's key signature for Tailnet Lock. If `None`, has not changed.
    pub tailnet_lock_key_signature: Option<Vec<u8>>,

    /// The underlay addresses this node is reachable on (`Endpoints` in Go). If `None`, has not
    /// changed.
    pub underlay_addresses: Option<Vec<SocketAddr>>,
}

impl From<&ts_control_serde::PeerChange<'_>> for NodeUpdate {
    fn from(value: &ts_control_serde::PeerChange<'_>) -> Self {
        let cap_map = value.cap_map.as_ref().map(|m| {
            m.iter()
                .map(|(name, values)| {
                    (
                        String::from(*name),
                        values
                            .0
                            .iter()
                            .map(|v| v.to_string())
                            .collect::<Vec<String>>(),
                    )
                })
                .collect()
        });

        Self {
            id: value.node_id,
            status: NodeStatus::new(value.online, value.last_seen),
            derp_region: value.derp_region.map(|x| ts_derp::RegionId(x.into())),
            cap: value.cap,
            cap_map,
            node_key: value.key,
            node_key_expiry: value.key_expiry,
            disco_key: value.disco_key,
            tailnet_lock_key_signature: value.key_signature.map(|x| x.into()),
            underlay_addresses: value.endpoints.clone(),
        }
    }
}