1- use core:: net:: { IpAddr , SocketAddr } ;
1+ use alloc:: collections:: BTreeMap ;
2+ use core:: {
3+ fmt,
4+ net:: { IpAddr , SocketAddr } ,
5+ } ;
26
37use chrono:: { DateTime , Utc } ;
8+ use ts_capabilityversion:: CapabilityVersion ;
49use ts_keys:: { DiscoPublicKey , MachinePublicKey , NodePublicKey } ;
510
11+ const LAST_SEEN_FORMAT : & ' static str = "%F %T %Z" ;
12+
613/// The unique id of a node.
714pub type Id = i64 ;
815
916/// The stable ID of a node.
1017#[ derive( Debug , Clone , PartialEq , Eq , Hash ) ]
1118pub 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 ) ]
15144pub 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
52195impl 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