Skip to content

Commit aa8badd

Browse files
ts_capability_version, ts_control*, ts_runtime, ts_nodecapability: handle PeerChange, OnlineStatus, PeerSeen messages
Applies peer updates from MapResponse::{peers_changed_patch, peer_seen_change, online_change} to the peer tracker's `Node` state. Introduces a number of types to ease handling of patch/delta updates, especially concerning online and last-seen timestamps. Updates order that delta updates are applied to match the Golang codebase. Signed-off-by: Dylan Bargatze <dylan@tailscale.com>
1 parent dd55a2e commit aa8badd

12 files changed

Lines changed: 413 additions & 37 deletions

File tree

ts_capabilityversion/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use core::fmt;
1616
///
1717
/// Note: Prior to 2022-03-06, this value was known as the "`MapRequest` version", `mapVer`, or "map
1818
/// cap"; you'll still see that name used in comments throughout the Golang codebase.
19-
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
19+
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
2020
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2121
pub struct CapabilityVersion(u16);
2222

ts_control/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ ts_transport_derp.workspace = true
2828

2929
# Unconditionally required dependencies.
3030
bytes.workspace = true
31-
chrono = { workspace = true, features = ["serde"] }
31+
chrono = { workspace = true, features = ["clock", "serde"] }
3232
gethostname.workspace = true
3333
ipnet = { workspace = true, features = ["serde"] }
3434
lazy_static.workspace = true

ts_control/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ pub use config::{Config, DEFAULT_CONTROL_SERVER};
2828
pub use control_dialer::{ControlDialer, TcpDialer, complete_connection};
2929
pub use derp::{Map as DerpMap, Region as DerpRegion, convert_derp_map};
3030
pub use dial_plan::{DialCandidate, DialMode, DialPlan};
31-
pub use node::{Id as NodeId, Node, StableId as StableNodeId, TailnetAddress};
31+
pub use node::{
32+
Id as NodeId, Node, NodeStatus, NodeUpdate, StableId as StableNodeId, TailnetAddress,
33+
};
3234

3335
#[cfg(feature = "async_tokio")]
3436
pub use crate::tokio::{AsyncControlClient, FilterUpdate, PeerUpdate, StateUpdate};

ts_control/src/node.rs

Lines changed: 283 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,169 @@
1-
use core::net::{IpAddr, SocketAddr};
1+
use alloc::collections::BTreeMap;
2+
use core::{
3+
fmt,
4+
net::{IpAddr, SocketAddr},
5+
};
26

37
use chrono::{DateTime, Utc};
8+
use ts_capabilityversion::CapabilityVersion;
49
use ts_keys::{DiscoPublicKey, MachinePublicKey, NodePublicKey};
510

11+
const LAST_SEEN_FORMAT: &'static str = "%F %T %Z";
12+
613
/// The unique id of a node.
714
pub type Id = i64;
815

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

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)]
27+
pub 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+
55+
impl 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+
67+
impl 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+
81+
impl 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)]
95+
pub 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+
105+
impl 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+
115+
impl 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+
13142
/// A node in a tailnet.
14143
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15144
pub struct Node {
16145
/// The node's id.
17146
pub id: Id,
18147
/// The node's stable id.
19148
pub stable_id: StableId,
20-
21-
/// This node's hostname.
149+
/// The node's hostname.
22150
pub hostname: String,
23151

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+
24161
/// The tailnet this node belongs to.
25162
pub tailnet: Option<String>,
26163

164+
/// The node capabilities assigned to this node.
165+
pub node_capabilities: BTreeMap<String, Vec<String>>,
166+
27167
/// The tags assigned to this node.
28168
pub tags: Vec<String>,
29169

@@ -39,6 +179,9 @@ pub struct Node {
39179
pub machine_key: Option<MachinePublicKey>,
40180
/// The node's [`DiscoPublicKey`], if known.
41181
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>>,
42185

43186
/// The routes this node accepts traffic for.
44187
pub accepted_routes: Vec<ipnet::IpNet>,
@@ -50,6 +193,61 @@ pub struct Node {
50193
}
51194

52195
impl 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+
53251
/// The fully-qualified domain name of the node.
54252
///
55253
/// This is a string of the form `$HOST.$TAILNET_DOMAIN.`. For tailnets controlled by
@@ -124,10 +322,18 @@ impl From<&ts_control_serde::Node<'_>> for Node {
124322
Self {
125323
id: value.id,
126324
stable_id: StableId(value.stable_id.0.to_string()),
127-
128325
hostname: hostname.to_owned(),
326+
327+
capability_version: value.cap,
328+
329+
status: NodeStatus::new(value.online, value.last_seen),
129330
tailnet,
130331

332+
node_capabilities: value
333+
.cap_map
334+
.iter()
335+
.map(|(k, v)| (k.to_string(), v.into()))
336+
.collect(),
131337
tags: value
132338
.tags
133339
.as_ref()
@@ -142,6 +348,7 @@ impl From<&ts_control_serde::Node<'_>> for Node {
142348
node_key_expiry: value.key_expiry,
143349
machine_key: value.machine,
144350
disco_key: value.disco_key,
351+
tailnet_lock_key_signature: value.key_signature.as_ref().map(|s| Vec::<u8>::from(*s)),
145352

146353
accepted_routes: value
147354
.allowed_ips
@@ -159,3 +366,75 @@ impl From<&ts_control_serde::Node<'_>> for Node {
159366
}
160367
}
161368
}
369+
370+
/// A partial update to a [`Node`] in a tailnet.
371+
///
372+
/// Devices should reject any `NodeUpdate` that it doesn't have a corresponding [`Node`] for. This
373+
/// type combines multiple different update/patch-style fields from netmap messages into a single
374+
/// type to simplify update handling for interested components, such as the peer tracker.
375+
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
376+
pub struct NodeUpdate {
377+
/// The node's id.
378+
pub id: Id,
379+
380+
/// Whether this node is online or offline (with last-seen timestamp), according to the control
381+
/// plane. If [`NodeStatus::Unknown`], has not changed.
382+
pub status: NodeStatus,
383+
384+
/// The DERP region for this node. If `None`, has not changed.
385+
pub derp_region: Option<ts_transport_derp::RegionId>,
386+
387+
/// The node's capability version. If `None`, has not changed.
388+
pub cap: Option<CapabilityVersion>,
389+
/// The node's capabilities (node caps, not peer caps). If `None`, has not changed.
390+
pub cap_map: Option<BTreeMap<String, Vec<String>>>,
391+
392+
/// The node's [`NodePublicKey`]. If `None`, has not changed.
393+
pub node_key: Option<NodePublicKey>,
394+
/// The node key's expiration. If `None`, has not changed.
395+
pub node_key_expiry: Option<DateTime<Utc>>,
396+
397+
/// The node's [`DiscoPublicKey`]. If `None`, has not changed.
398+
pub disco_key: Option<DiscoPublicKey>,
399+
400+
/// The node's key signature for Tailnet Lock. If `None`, has not changed.
401+
pub tailnet_lock_key_signature: Option<Vec<u8>>,
402+
403+
/// The underlay addresses this node is reachable on (`Endpoints` in Go). If `None`, has not
404+
/// changed.
405+
pub underlay_addresses: Option<Vec<SocketAddr>>,
406+
}
407+
408+
impl From<&ts_control_serde::PeerChange<'_>> for NodeUpdate {
409+
fn from(value: &ts_control_serde::PeerChange<'_>) -> Self {
410+
let cap_map = value.cap_map.as_ref().map(|m| {
411+
m.iter()
412+
.map(|(name, values)| {
413+
(
414+
String::from(*name),
415+
values
416+
.0
417+
.iter()
418+
.map(|v| v.to_string())
419+
.collect::<Vec<String>>(),
420+
)
421+
})
422+
.collect()
423+
});
424+
425+
Self {
426+
id: value.node_id,
427+
status: NodeStatus::new(value.online, value.last_seen),
428+
derp_region: value
429+
.derp_region
430+
.map(|x| ts_transport_derp::RegionId(x.into())),
431+
cap: value.cap,
432+
cap_map,
433+
node_key: value.key,
434+
node_key_expiry: value.key_expiry,
435+
disco_key: value.disco_key,
436+
tailnet_lock_key_signature: value.key_signature.map(|x| x.into()),
437+
underlay_addresses: value.endpoints.clone(),
438+
}
439+
}
440+
}

0 commit comments

Comments
 (0)