Skip to main content

ts_control/
node.rs

1use alloc::collections::BTreeMap;
2use core::{
3    fmt,
4    net::{IpAddr, SocketAddr},
5};
6
7use chrono::{DateTime, Utc};
8use ts_capabilityversion::CapabilityVersion;
9use ts_keys::{DiscoPublicKey, MachinePublicKey, NodePublicKey};
10
11const LAST_SEEN_FORMAT: &str = "%F %T %Z";
12
13/// The unique id of a node.
14pub type Id = i64;
15
16/// The stable ID of a node.
17#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
18pub struct StableId(pub String);
19
20/// Timestamps indicating when an offline node was last connected to the control plane. Only applies
21/// to nodes that are offline (disconnected from the control plane).
22///
23/// If populated, most `control` timestamp values are only accurate to a resolution of ~10 minutes
24/// for privacy reasons. See each variant for important information on comparison of timestamp
25/// values and which clock is used to generate a timestamp.
26#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
27pub struct NodeLastSeen {
28    /// The last time the node was connected to the control plane, as reported by the control plane
29    /// itself. In some cases, the control plane does not report a "last seen" timestamp, just that
30    /// a node is offline; in those cases, this field will be `None`.
31    ///
32    /// The timestamp may be rounded to the nearest 10-minute boundary before being reported to this
33    /// device by the control plane; in other words, many `control` timestamps will be aligned to
34    /// 10-minute boundaries. This is a privacy-preserving measure implemented by the control plane.
35    /// Note that we have seen some non-rounded timestamps being reported from the control plane,
36    /// although it's unclear if this is a bug or by design.
37    ///
38    /// Note that the timestamp value was generated by the control plane. The control plane's
39    /// "clock" isn't synchronized with the local device's clock, so the timestamp value may be
40    /// much more than 5 minutes in the past/future when compared to the `estimated` timestamp/local
41    /// device clock. Do not directly compare `NodeLastSeen::control` timestamps with
42    /// `NodeLastSeen::estimated` timestamps.
43    pub control: Option<DateTime<Utc>>,
44    /// The last time the node was connected to the control plane, as estimated by this device. The
45    /// timestamp is roughly the time this device was notified the node was offline by the control
46    /// plane, using the local device clock.
47    ///
48    /// Note that the timestamp value was generated by the local device clock. The control plane's
49    /// "clock" isn't synchronized with the local device's clock, so the timestamp value may differ
50    /// greatly from the `control` timestamp/control plane clock. Do not directly compare
51    /// `NodeLastSeen::control` timestamps with `NodeLastSeen::estimated` timestamps.
52    pub estimated: DateTime<Utc>,
53}
54
55impl Default for NodeLastSeen {
56    fn default() -> Self {
57        Self {
58            control: None,
59            // We intentionally don't fuzz the estimated timestamp value because the Go client code
60            // doesn't either.
61            // See: https://github.com/tailscale/tailscale/blob/ee0a03b140021541495b25bdb6642b589431758b/control/controlclient/map.go#L753-L764
62            estimated: Utc::now(),
63        }
64    }
65}
66
67impl fmt::Display for NodeLastSeen {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        write!(
70            f,
71            "last seen: {} (by control: ",
72            self.estimated.format(LAST_SEEN_FORMAT)
73        )?;
74        match self.control {
75            None => write!(f, "unknown)"),
76            Some(dt) => write!(f, "{})", dt.format(LAST_SEEN_FORMAT)),
77        }
78    }
79}
80
81impl NodeLastSeen {
82    /// Construct a new `NodeLastSeen` with an optional "last seen by control" timestamp. The
83    /// `estimated` timestamp is always set to the current UTC date/time.
84    pub fn new(control: Option<DateTime<Utc>>) -> Self {
85        Self {
86            control,
87            ..Default::default()
88        }
89    }
90}
91
92/// Whether a node is online (connected to the control plane) or offline (disconnected from the
93/// control plane).
94#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash)]
95pub enum NodeStatus {
96    /// The node may be online or offline; the control plane hasn't informed us yet.
97    #[default]
98    Unknown,
99    /// The node is online (connected to the control plane).
100    Online,
101    /// The node is offline (disconnected from the control plane).
102    Offline(NodeLastSeen),
103}
104
105impl fmt::Display for NodeStatus {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        match self {
108            NodeStatus::Unknown => write!(f, "unknown"),
109            NodeStatus::Online => write!(f, "online"),
110            NodeStatus::Offline(nls) => write!(f, "offline, {nls}"),
111        }
112    }
113}
114
115impl NodeStatus {
116    /// Construct a new `NodeStatus` from a combination of online and last-seen timestamp.
117    ///
118    /// The netmap messages we get from the control plane have multiple ways to indicate a peer is
119    /// online and/or the timestamp the peer was last connected to the control plane. This method
120    /// wraps the sometimes-painful logic of creating a `NodeStatus` from the various combinations
121    /// of netmap field values.
122    pub fn new(online: Option<bool>, last_seen: Option<DateTime<Utc>>) -> Self {
123        match (online, last_seen) {
124            (Some(true), None) => Self::Online,
125            (Some(false), None) => Self::Offline(NodeLastSeen::new(None)),
126            (Some(false), Some(dt)) | (None, Some(dt)) => {
127                Self::Offline(NodeLastSeen::new(Some(dt)))
128            }
129            (None, None) => Self::Unknown,
130            (online, last_seen) => {
131                tracing::warn!(
132                    ?online,
133                    ?last_seen,
134                    "unexpected combination of online/last_seen states"
135                );
136                Self::Unknown
137            }
138        }
139    }
140}
141
142/// A node in a tailnet.
143#[derive(Debug, Clone, PartialEq, Eq, Hash)]
144pub struct Node {
145    /// The node's id.
146    pub id: Id,
147    /// The node's stable id.
148    pub stable_id: StableId,
149    /// The node's hostname.
150    pub hostname: String,
151
152    /// The node's capability version.
153    pub capability_version: CapabilityVersion,
154
155    /// Whether the node is connected to the control plane or not. If [`NodeStatus::Unknown`], the
156    /// control plane hasn't told us yet.
157    ///
158    /// This field does _not_ indicate whether the node is reachable or visible from this device.
159    pub status: NodeStatus,
160
161    /// The tailnet this node belongs to.
162    pub tailnet: Option<String>,
163
164    /// The node capabilities assigned to this node.
165    pub node_capabilities: BTreeMap<String, Vec<String>>,
166
167    /// The tags assigned to this node.
168    pub tags: Vec<String>,
169
170    /// The address of the node in the tailnet.
171    pub tailnet_address: TailnetAddress,
172
173    /// The node's [`NodePublicKey`].
174    pub node_key: NodePublicKey,
175    /// The node key's expiration.
176    pub node_key_expiry: Option<DateTime<Utc>>,
177
178    /// The node's [`MachinePublicKey`], if known.
179    pub machine_key: Option<MachinePublicKey>,
180    /// The node's [`DiscoPublicKey`], if known.
181    pub disco_key: Option<DiscoPublicKey>,
182    /// The signature of the node's public key with the Tailnet Lock signing key, if Tailnet Lock
183    /// is enabled and the signature is known.
184    pub tailnet_lock_key_signature: Option<Vec<u8>>,
185
186    /// The routes this node accepts traffic for.
187    pub accepted_routes: Vec<ipnet::IpNet>,
188    /// The underlay addresses this node is reachable on (`Endpoints` in Go).
189    pub underlay_addresses: Vec<SocketAddr>,
190
191    /// The DERP region for this node, if known.
192    pub derp_region: Option<ts_derp::RegionId>,
193}
194
195impl Node {
196    /// Apply the given partial update to this `Node`.
197    ///
198    /// # Panics
199    /// If `update.id` does not match `self.id`. The caller must guarantee this method is only
200    /// called with [`NodeUpdate`]s for this `Node`.
201    #[tracing::instrument(skip_all, fields(id = self.stable_id.0, hostname = self.hostname))]
202    pub fn apply_update(&mut self, update: &NodeUpdate) {
203        assert_eq!(self.id, update.id, "node update ID != node ID");
204
205        if update.status != NodeStatus::Unknown {
206            tracing::debug!(old_status = %self.status, "peer {}", update.status);
207            self.status = update.status;
208        }
209
210        if let Some(cap) = update.cap {
211            tracing::debug!(old=?self.capability_version, new=?cap, "updating capability version");
212            self.capability_version = cap;
213        }
214
215        if let Some(cap_map) = update.cap_map.as_ref() {
216            tracing::debug!(old=?self.node_capabilities, new=?cap_map, "updating node capabilities");
217            self.node_capabilities = cap_map.clone();
218        }
219
220        if let Some(derp_region) = update.derp_region {
221            tracing::debug!(old=?self.derp_region, new=?derp_region, "updating derp home region");
222            self.derp_region = Some(derp_region);
223        }
224
225        if let Some(disco_key) = update.disco_key {
226            tracing::debug!(old=?self.disco_key, new=?disco_key, "updating disco key");
227            self.disco_key = Some(disco_key);
228        }
229
230        if let Some(node_key) = update.node_key {
231            tracing::debug!(old=?self.node_key, new=?node_key, "updating node key");
232            self.node_key = node_key;
233        }
234
235        if let Some(node_key_expiry) = update.node_key_expiry {
236            tracing::debug!(old=?self.node_key_expiry, new=?node_key_expiry, "updating node key expiry");
237            self.node_key_expiry = Some(node_key_expiry);
238        }
239
240        if let Some(tl_sig) = &update.tailnet_lock_key_signature {
241            tracing::debug!(old=?self.tailnet_lock_key_signature, new=?tl_sig, "updating tailnet lock key signature");
242            self.tailnet_lock_key_signature = Some(tl_sig.clone());
243        }
244
245        if let Some(underlay_addresses) = &update.underlay_addresses {
246            tracing::debug!(old=?self.underlay_addresses, new=?underlay_addresses, "updating underlay addresses");
247            self.underlay_addresses = underlay_addresses.clone();
248        }
249    }
250
251    /// The fully-qualified domain name of the node.
252    ///
253    /// This is a string of the form `$HOST.$TAILNET_DOMAIN.`. For tailnets controlled by
254    /// Tailscale's control plane, this usually means `$HOST.tail1234.ts.net.`
255    ///
256    /// The `trailing_dot` parameter specifies whether to include the trailing dot in the
257    /// fqdn. This is included by the definition of FQDN, and is the way the Go codebase
258    /// formats this field, but the parameter is included to allow turning it off for use
259    /// in contexts that expect it to be absent.
260    pub fn fqdn(&self, trailing_dot: bool) -> String {
261        let dot = if trailing_dot { "." } else { "" };
262        match &self.tailnet {
263            Some(tailnet) => format!("{}.{tailnet}{dot}", self.hostname),
264            None => format!("{}{dot}", self.hostname),
265        }
266    }
267
268    /// The fully-qualified domain name of the node, only returning `Some` if the tailnet
269    /// component is present.
270    ///
271    /// See [`Node::fqdn`].
272    pub fn fqdn_opt(&self, trailing_dot: bool) -> Option<String> {
273        let dot = if trailing_dot { "." } else { "" };
274        let tailnet = self.tailnet.as_deref()?;
275
276        Some(format!("{}.{tailnet}{dot}", self.hostname))
277    }
278
279    /// Report whether this node matches the given `name`.
280    ///
281    /// `name` is checked for equality with both this node's bare hostname and its fqdn. A
282    /// trailing `.` may be present.
283    pub fn matches_name(&self, name: &str) -> bool {
284        // This approach is taken to avoid allocating a buffer just for the sake of making this
285        // comparison: try to chop `.tailnet.` off of the end of `name` and compare the
286        // remainder to our hostname. If `.tailnet.` doesn't match `name`, we'll end up comparing
287        // our hostname to `hostname.other_tailnet.`, which won't succeed. If `name` was just the
288        // hostname, nothing will have been chopped, so the comparison will still be hostname-to-
289        // hostname.
290
291        let name = name.strip_suffix('.').unwrap_or(name);
292
293        let name = if let Some(tailnet) = &self.tailnet {
294            name.strip_suffix(tailnet.as_str())
295                .and_then(|name| name.strip_suffix('.'))
296                .unwrap_or(name)
297        } else {
298            name
299        };
300
301        name == self.hostname
302    }
303}
304
305/// Addresses for a node within a tailnet.
306#[derive(Debug, Clone, PartialEq, Eq, Hash)]
307pub struct TailnetAddress {
308    /// The IPv4 address of the node in the tailnet.
309    pub ipv4: ipnet::Ipv4Net,
310    /// The IPv6 address of the node in the tailnet.
311    pub ipv6: ipnet::Ipv6Net,
312}
313
314impl TailnetAddress {
315    /// Report whether `addr` matches either address in this [`TailnetAddress`].
316    pub fn contains(&self, addr: IpAddr) -> bool {
317        match addr {
318            IpAddr::V4(a) => self.ipv4.addr() == a,
319            IpAddr::V6(a) => self.ipv6.addr() == a,
320        }
321    }
322}
323
324impl From<&ts_control_serde::Node<'_>> for Node {
325    fn from(value: &ts_control_serde::Node) -> Self {
326        let fqdn_without_trailing_dot = value.name.strip_suffix('.').unwrap_or(value.name);
327
328        let (hostname, tailnet) = match fqdn_without_trailing_dot.split_once('.') {
329            Some((hostname, tailnet)) => (hostname, Some(tailnet.to_owned())),
330            None => (fqdn_without_trailing_dot, None),
331        };
332
333        Self {
334            id: value.id,
335            stable_id: StableId(value.stable_id.0.to_string()),
336            hostname: hostname.to_owned(),
337
338            capability_version: value.cap,
339
340            status: NodeStatus::new(value.online, value.last_seen),
341            tailnet,
342
343            node_capabilities: value
344                .cap_map
345                .iter()
346                .map(|(k, v)| (k.to_string(), v.into()))
347                .collect(),
348            tags: value
349                .tags
350                .as_ref()
351                .map(|x| x.iter().map(|x| x.to_string()).collect())
352                .unwrap_or_default(),
353
354            tailnet_address: TailnetAddress {
355                ipv4: value.addresses.0,
356                ipv6: value.addresses.1,
357            },
358            node_key: value.key,
359            node_key_expiry: value.key_expiry,
360            machine_key: value.machine,
361            disco_key: value.disco_key,
362            tailnet_lock_key_signature: value.key_signature.as_ref().map(|s| Vec::<u8>::from(*s)),
363
364            accepted_routes: value
365                .allowed_ips
366                .clone()
367                .unwrap_or_else(|| vec![value.addresses.0.into(), value.addresses.1.into()]),
368            underlay_addresses: value.endpoints.clone(),
369
370            // legacy_derp_string is still in practical use as of 3/2026
371            #[allow(deprecated)]
372            derp_region: value
373                .home_derp
374                .or(value.legacy_derp_string)
375                .or_else(|| value.host_info.net_info.as_ref()?.preferred_derp)
376                .map(|x| ts_derp::RegionId(x.into())),
377        }
378    }
379}
380
381/// A partial update to a [`Node`] in a tailnet.
382///
383/// Devices should reject any `NodeUpdate` that it doesn't have a corresponding [`Node`] for. This
384/// type combines multiple different update/patch-style fields from netmap messages into a single
385/// type to simplify update handling for interested components, such as the peer tracker.
386#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
387pub struct NodeUpdate {
388    /// The node's id.
389    pub id: Id,
390
391    /// Whether this node is online or offline (with last-seen timestamp), according to the control
392    /// plane. If [`NodeStatus::Unknown`], has not changed.
393    pub status: NodeStatus,
394
395    /// The DERP region for this node. If `None`, has not changed.
396    pub derp_region: Option<ts_derp::RegionId>,
397
398    /// The node's capability version. If `None`, has not changed.
399    pub cap: Option<CapabilityVersion>,
400    /// The node's capabilities (node caps, not peer caps). If `None`, has not changed.
401    pub cap_map: Option<BTreeMap<String, Vec<String>>>,
402
403    /// The node's [`NodePublicKey`]. If `None`, has not changed.
404    pub node_key: Option<NodePublicKey>,
405    /// The node key's expiration. If `None`, has not changed.
406    pub node_key_expiry: Option<DateTime<Utc>>,
407
408    /// The node's [`DiscoPublicKey`]. If `None`, has not changed.
409    pub disco_key: Option<DiscoPublicKey>,
410
411    /// The node's key signature for Tailnet Lock. If `None`, has not changed.
412    pub tailnet_lock_key_signature: Option<Vec<u8>>,
413
414    /// The underlay addresses this node is reachable on (`Endpoints` in Go). If `None`, has not
415    /// changed.
416    pub underlay_addresses: Option<Vec<SocketAddr>>,
417}
418
419impl From<&ts_control_serde::PeerChange<'_>> for NodeUpdate {
420    fn from(value: &ts_control_serde::PeerChange<'_>) -> Self {
421        let cap_map = value.cap_map.as_ref().map(|m| {
422            m.iter()
423                .map(|(name, values)| {
424                    (
425                        String::from(*name),
426                        values
427                            .0
428                            .iter()
429                            .map(|v| v.to_string())
430                            .collect::<Vec<String>>(),
431                    )
432                })
433                .collect()
434        });
435
436        Self {
437            id: value.node_id,
438            status: NodeStatus::new(value.online, value.last_seen),
439            derp_region: value.derp_region.map(|x| ts_derp::RegionId(x.into())),
440            cap: value.cap,
441            cap_map,
442            node_key: value.key,
443            node_key_expiry: value.key_expiry,
444            disco_key: value.disco_key,
445            tailnet_lock_key_signature: value.key_signature.map(|x| x.into()),
446            underlay_addresses: value.endpoints.clone(),
447        }
448    }
449}