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}