Skip to content

Commit cd95736

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 7751a47 commit cd95736

12 files changed

Lines changed: 394 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: 259 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
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

611
/// The unique id of a node.
@@ -10,20 +15,125 @@ pub type Id = i64;
1015
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1116
pub struct StableId(pub String);
1217

18+
/// Timestamp when an offline node was last connected to the control plane. Only applies to nodes
19+
/// that are offline (disconnected from the control plane).
20+
///
21+
/// Some timestamp values are only accurate to a resolution of ~10 minutes for privacy reasons, and
22+
/// do not take into account differences between the control plane clock and local device clock. See
23+
/// each variant for more information.
24+
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
25+
pub enum NodeLastSeen {
26+
/// The last time the node was connected to the control plane, as reported by the control plane
27+
/// itself.
28+
///
29+
/// The timestamp value may be rounded to the nearest 10-minute boundary before being reported
30+
/// to this device by the control plane; in other words, many `Control` timestamps will be
31+
/// aligned to 10-minute boundaries. This is a privacy-preserving measure implemented by the
32+
/// control plane. Note that we have seen some non-rounded timestamps being reported from the
33+
/// control plane, although it's unclear if this is a bug or by design.
34+
///
35+
/// Note that the timestamp value was generated by the control plane. The control plane's
36+
/// "clock" isn't synchronized with the local device's clock, so the timestamp value may be
37+
/// much more than 5 minutes in the past/future when compared to the local device clock.
38+
Control(DateTime<Utc>),
39+
/// The last time the node was connected to the control plane, as estimated by this device.
40+
///
41+
/// For some types of node updates, we're only notified that the node is offline, not when it
42+
/// was last seen by the control plane. When we receive one of these offline updates, we
43+
/// estimate a last-seen timestamp using the local device clock. As the control plane "clock"
44+
/// and the local device clock aren't synchronized, this estimate may differ much more than 5
45+
/// minutes in the past/future when compared to a fuzzed timestamp generated by the control
46+
/// plane "clock".
47+
Estimated(DateTime<Utc>),
48+
}
49+
50+
impl fmt::Display for NodeLastSeen {
51+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52+
match self {
53+
NodeLastSeen::Control(dt) => write!(f, "last seen (by control): {}", dt),
54+
NodeLastSeen::Estimated(dt) => write!(f, "last seen (estimated): {}", dt),
55+
}
56+
}
57+
}
58+
59+
/// Whether a node is online (connected to the control plane) or offline (disconnected from the
60+
/// control plane).
61+
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash)]
62+
pub enum NodeStatus {
63+
/// The node may be online or offline; the control plane hasn't informed us yet.
64+
#[default]
65+
Unknown,
66+
/// The node is online (connected to the control plane).
67+
Online,
68+
/// The node is offline (disconnected from the control plane).
69+
Offline(NodeLastSeen),
70+
}
71+
72+
impl fmt::Display for NodeStatus {
73+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74+
match self {
75+
NodeStatus::Unknown => write!(f, "unknown"),
76+
NodeStatus::Online => write!(f, "online"),
77+
NodeStatus::Offline(nls) => write!(f, "offline, {nls}"),
78+
}
79+
}
80+
}
81+
82+
impl NodeStatus {
83+
/// Construct a new `NodeStatus` from a combination of online and last-seen timestamp.
84+
///
85+
/// The netmap messages we get from the control plane have multiple ways to indicate a peer is
86+
/// online and/or the timestamp the peer was last connected to the control plane. This method
87+
/// wraps the sometimes-painful logic of creating a `NodeStatus` from the various combinations
88+
/// of netmap field values.
89+
pub fn new(online: Option<bool>, last_seen: Option<DateTime<Utc>>) -> Self {
90+
match (online, last_seen) {
91+
(Some(true), None) => Self::Online,
92+
(Some(false), None) => {
93+
// We intentionally don't fuzz the estimated timestamp value because the Go client
94+
// code doesn't either.
95+
// See: https://github.com/tailscale/tailscale/blob/ee0a03b140021541495b25bdb6642b589431758b/control/controlclient/map.go#L753-L764
96+
Self::Offline(NodeLastSeen::Estimated(Utc::now()))
97+
}
98+
(Some(false), Some(dt)) | (None, Some(dt)) => Self::Offline(NodeLastSeen::Control(dt)),
99+
(None, None) => Self::Unknown,
100+
(online, last_seen) => {
101+
tracing::warn!(
102+
?online,
103+
?last_seen,
104+
"unexpected combination of online/last_seen states"
105+
);
106+
Self::Unknown
107+
}
108+
}
109+
}
110+
}
111+
13112
/// A node in a tailnet.
14113
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15114
pub struct Node {
16115
/// The node's id.
17116
pub id: Id,
18117
/// The node's stable id.
19118
pub stable_id: StableId,
20-
21-
/// This node's hostname.
119+
/// The node's hostname.
22120
pub hostname: String,
23121

122+
/// The node's capability version.
123+
pub capability_version: CapabilityVersion,
124+
125+
/// Whether the node is connected to the control plane or not. If [`NodeStatus::Unknown`], the
126+
/// control plane hasn't told us yet.
127+
///
128+
/// This field does _not_ indicate whether the node is reachable or visible from this device.
129+
pub status: NodeStatus,
130+
24131
/// The tailnet this node belongs to.
25132
pub tailnet: Option<String>,
26133

134+
/// The node capabilities assigned to this node.
135+
pub node_capabilities: BTreeMap<String, Vec<String>>,
136+
27137
/// The tags assigned to this node.
28138
pub tags: Vec<String>,
29139

@@ -39,6 +149,9 @@ pub struct Node {
39149
pub machine_key: Option<MachinePublicKey>,
40150
/// The node's [`DiscoPublicKey`], if known.
41151
pub disco_key: Option<DiscoPublicKey>,
152+
/// The signature of the node's public key with the Tailnet Lock signing key, if Tailnet Lock
153+
/// is enabled and the signature is known.
154+
pub tailnet_lock_key_signature: Option<Vec<u8>>,
42155

43156
/// The routes this node accepts traffic for.
44157
pub accepted_routes: Vec<ipnet::IpNet>,
@@ -50,6 +163,67 @@ pub struct Node {
50163
}
51164

52165
impl Node {
166+
/// Apply the given partial update to this `Node`. Returns `true` if the update was successfully
167+
/// applied; `false` otherwise.
168+
#[tracing::instrument(skip_all, fields(id = self.stable_id.0, hostname = self.hostname))]
169+
pub fn apply_update(&mut self, update: &NodeUpdate) -> bool {
170+
if self.id != update.id {
171+
tracing::warn!(
172+
node_id = self.id,
173+
update_id = update.id,
174+
"ignoring node update with incorrect node ID"
175+
);
176+
return false;
177+
}
178+
179+
if update.status != NodeStatus::Unknown {
180+
tracing::debug!(old_status = %self.status, "peer {}", update.status);
181+
self.status = update.status;
182+
}
183+
184+
if let Some(cap) = update.cap {
185+
tracing::debug!(old=?self.capability_version, new=?cap, "updating capability version");
186+
self.capability_version = cap;
187+
}
188+
189+
if let Some(cap_map) = update.cap_map.as_ref() {
190+
tracing::debug!(old=?self.node_capabilities, new=?cap_map, "updating node capabilities");
191+
self.node_capabilities = cap_map.clone();
192+
}
193+
194+
if let Some(derp_region) = update.derp_region {
195+
tracing::debug!(old=?self.derp_region, new=?derp_region, "updating derp home region");
196+
self.derp_region = Some(derp_region);
197+
}
198+
199+
if let Some(disco_key) = update.disco_key {
200+
tracing::debug!(old=?self.disco_key, new=?disco_key, "updating disco key");
201+
self.disco_key = Some(disco_key);
202+
}
203+
204+
if let Some(node_key) = update.node_key {
205+
tracing::debug!(old=?self.node_key, new=?node_key, "updating node key");
206+
self.node_key = node_key;
207+
}
208+
209+
if let Some(node_key_expiry) = update.node_key_expiry {
210+
tracing::debug!(old=?self.node_key_expiry, new=?node_key_expiry, "updating node key expiry");
211+
self.node_key_expiry = Some(node_key_expiry);
212+
}
213+
214+
if let Some(tl_sig) = &update.tailnet_lock_key_signature {
215+
tracing::debug!(old=?self.tailnet_lock_key_signature, new=?tl_sig, "updating tailnet lock key signature");
216+
self.tailnet_lock_key_signature = Some(tl_sig.clone());
217+
}
218+
219+
if let Some(underlay_addresses) = &update.underlay_addresses {
220+
tracing::debug!(old=?self.underlay_addresses, new=?underlay_addresses, "updating underlay addresses");
221+
self.underlay_addresses = underlay_addresses.clone();
222+
}
223+
224+
true
225+
}
226+
53227
/// The fully-qualified domain name of the node.
54228
///
55229
/// This is a string of the form `$HOST.$TAILNET_DOMAIN.`. For tailnets controlled by
@@ -124,10 +298,18 @@ impl From<&ts_control_serde::Node<'_>> for Node {
124298
Self {
125299
id: value.id,
126300
stable_id: StableId(value.stable_id.0.to_string()),
127-
128301
hostname: hostname.to_owned(),
302+
303+
capability_version: value.cap,
304+
305+
status: NodeStatus::new(value.online, value.last_seen),
129306
tailnet,
130307

308+
node_capabilities: value
309+
.cap_map
310+
.iter()
311+
.map(|(k, v)| (k.to_string(), v.into()))
312+
.collect(),
131313
tags: value
132314
.tags
133315
.as_ref()
@@ -142,6 +324,7 @@ impl From<&ts_control_serde::Node<'_>> for Node {
142324
node_key_expiry: value.key_expiry,
143325
machine_key: value.machine,
144326
disco_key: value.disco_key,
327+
tailnet_lock_key_signature: value.key_signature.as_ref().map(|s| Vec::<u8>::from(*s)),
145328

146329
accepted_routes: value
147330
.allowed_ips
@@ -159,3 +342,75 @@ impl From<&ts_control_serde::Node<'_>> for Node {
159342
}
160343
}
161344
}
345+
346+
/// A partial update to a [`Node`] in a tailnet.
347+
///
348+
/// Devices should reject any `NodeUpdate` that it doesn't have a corresponding [`Node`] for. This
349+
/// type combines multiple different update/patch-style fields from netmap messages into a single
350+
/// type, such as the
351+
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
352+
pub struct NodeUpdate {
353+
/// The node's id.
354+
pub id: Id,
355+
356+
/// Whether this node is online or offline (with last-seen timestamp), according to the control
357+
/// plane. If [`NodeStatus::Unknown`], has not changed.
358+
pub status: NodeStatus,
359+
360+
/// The DERP region for this node. If `None`, has not changed.
361+
pub derp_region: Option<ts_transport_derp::RegionId>,
362+
363+
/// The node's capability version. If `None`, has not changed.
364+
pub cap: Option<CapabilityVersion>,
365+
/// The node's capabilities (node caps, not peer caps). If `None`, has not changed.
366+
pub cap_map: Option<BTreeMap<String, Vec<String>>>,
367+
368+
/// The node's [`NodePublicKey`]. If `None`, has not changed.
369+
pub node_key: Option<NodePublicKey>,
370+
/// The node key's expiration. If `None`, has not changed.
371+
pub node_key_expiry: Option<DateTime<Utc>>,
372+
373+
/// The node's [`DiscoPublicKey`]. If `None`, has not changed.
374+
pub disco_key: Option<DiscoPublicKey>,
375+
376+
/// The node's key signature for Tailnet Lock. If `None`, has not changed.
377+
pub tailnet_lock_key_signature: Option<Vec<u8>>,
378+
379+
/// The underlay addresses this node is reachable on (`Endpoints` in Go). If `None`, has not
380+
/// changed.
381+
pub underlay_addresses: Option<Vec<SocketAddr>>,
382+
}
383+
384+
impl From<&ts_control_serde::PeerChange<'_>> for NodeUpdate {
385+
fn from(value: &ts_control_serde::PeerChange<'_>) -> Self {
386+
let cap_map = value.cap_map.as_ref().map(|m| {
387+
m.iter()
388+
.map(|(name, values)| {
389+
(
390+
String::from(*name),
391+
values
392+
.0
393+
.iter()
394+
.map(|v| v.to_string())
395+
.collect::<Vec<String>>(),
396+
)
397+
})
398+
.collect()
399+
});
400+
401+
Self {
402+
id: value.node_id,
403+
status: NodeStatus::new(value.online, value.last_seen),
404+
derp_region: value
405+
.derp_region
406+
.map(|x| ts_transport_derp::RegionId(x.into())),
407+
cap: value.cap,
408+
cap_map,
409+
node_key: value.key,
410+
node_key_expiry: value.key_expiry,
411+
disco_key: value.disco_key,
412+
tailnet_lock_key_signature: value.key_signature.map(|x| x.into()),
413+
underlay_addresses: value.endpoints.clone(),
414+
}
415+
}
416+
}

0 commit comments

Comments
 (0)