diff --git a/Cargo.lock b/Cargo.lock index 36ee3e87..be50a5b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6103,6 +6103,7 @@ dependencies = [ name = "ts_runtime" version = "0.3.3" dependencies = [ + "chrono", "futures", "ipnet", "itertools", @@ -6116,6 +6117,7 @@ dependencies = [ "tracing", "ts_bart", "ts_bart_packetfilter", + "ts_capabilityversion", "ts_control", "ts_dataplane", "ts_derp", diff --git a/ts_capabilityversion/src/lib.rs b/ts_capabilityversion/src/lib.rs index 454f296d..2ab2d69b 100644 --- a/ts_capabilityversion/src/lib.rs +++ b/ts_capabilityversion/src/lib.rs @@ -16,7 +16,7 @@ use core::fmt; /// /// Note: Prior to 2022-03-06, this value was known as the "`MapRequest` version", `mapVer`, or "map /// cap"; you'll still see that name used in comments throughout the Golang codebase. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct CapabilityVersion(u16); diff --git a/ts_control/Cargo.toml b/ts_control/Cargo.toml index ebaa114b..e82f62fc 100644 --- a/ts_control/Cargo.toml +++ b/ts_control/Cargo.toml @@ -28,7 +28,7 @@ ts_derp.workspace = true # Unconditionally required dependencies. bytes.workspace = true -chrono = { workspace = true, features = ["serde"] } +chrono = { workspace = true, features = ["clock", "serde"] } gethostname.workspace = true ipnet = { workspace = true, features = ["serde"] } lazy_static.workspace = true diff --git a/ts_control/src/lib.rs b/ts_control/src/lib.rs index 79b39cf6..6ed8b12e 100644 --- a/ts_control/src/lib.rs +++ b/ts_control/src/lib.rs @@ -28,7 +28,10 @@ pub use config::{Config, DEFAULT_CONTROL_SERVER}; pub use control_dialer::{ControlDialer, TcpDialer, complete_connection}; pub use derp::{Map as DerpMap, Region as DerpRegion, convert_derp_map}; pub use dial_plan::{DialCandidate, DialMode, DialPlan}; -pub use node::{Id as NodeId, Node, StableId as StableNodeId, TailnetAddress}; +pub use node::{ + Id as NodeId, Node, NodeLastSeen, NodeStatus, NodeUpdate, StableId as StableNodeId, + TailnetAddress, +}; #[cfg(feature = "async_tokio")] pub use crate::tokio::{AsyncControlClient, FilterUpdate, PeerUpdate, StateUpdate}; diff --git a/ts_control/src/node.rs b/ts_control/src/node.rs index ad498c77..517d46b7 100644 --- a/ts_control/src/node.rs +++ b/ts_control/src/node.rs @@ -1,8 +1,15 @@ -use core::net::{IpAddr, SocketAddr}; +use alloc::collections::BTreeMap; +use core::{ + fmt, + net::{IpAddr, SocketAddr}, +}; use chrono::{DateTime, Utc}; +use ts_capabilityversion::CapabilityVersion; use ts_keys::{DiscoPublicKey, MachinePublicKey, NodePublicKey}; +const LAST_SEEN_FORMAT: &str = "%F %T %Z"; + /// The unique id of a node. pub type Id = i64; @@ -10,6 +17,128 @@ pub type Id = i64; #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct StableId(pub String); +/// Timestamps indicating when an offline node was last connected to the control plane. Only applies +/// to nodes that are offline (disconnected from the control plane). +/// +/// If populated, most `control` timestamp values are only accurate to a resolution of ~10 minutes +/// for privacy reasons. See each variant for important information on comparison of timestamp +/// values and which clock is used to generate a timestamp. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub struct NodeLastSeen { + /// The last time the node was connected to the control plane, as reported by the control plane + /// itself. In some cases, the control plane does not report a "last seen" timestamp, just that + /// a node is offline; in those cases, this field will be `None`. + /// + /// The timestamp may be rounded to the nearest 10-minute boundary before being reported to this + /// device by the control plane; in other words, many `control` timestamps will be aligned to + /// 10-minute boundaries. This is a privacy-preserving measure implemented by the control plane. + /// Note that we have seen some non-rounded timestamps being reported from the control plane, + /// although it's unclear if this is a bug or by design. + /// + /// Note that the timestamp value was generated by the control plane. The control plane's + /// "clock" isn't synchronized with the local device's clock, so the timestamp value may be + /// much more than 5 minutes in the past/future when compared to the `estimated` timestamp/local + /// device clock. Do not directly compare `NodeLastSeen::control` timestamps with + /// `NodeLastSeen::estimated` timestamps. + pub control: Option>, + /// The last time the node was connected to the control plane, as estimated by this device. The + /// timestamp is roughly the time this device was notified the node was offline by the control + /// plane, using the local device clock. + /// + /// Note that the timestamp value was generated by the local device clock. The control plane's + /// "clock" isn't synchronized with the local device's clock, so the timestamp value may differ + /// greatly from the `control` timestamp/control plane clock. Do not directly compare + /// `NodeLastSeen::control` timestamps with `NodeLastSeen::estimated` timestamps. + pub estimated: DateTime, +} + +impl Default for NodeLastSeen { + fn default() -> Self { + Self { + control: None, + // We intentionally don't fuzz the estimated timestamp value because the Go client code + // doesn't either. + // See: https://github.com/tailscale/tailscale/blob/ee0a03b140021541495b25bdb6642b589431758b/control/controlclient/map.go#L753-L764 + estimated: Utc::now(), + } + } +} + +impl fmt::Display for NodeLastSeen { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "last seen: {} (by control: ", + self.estimated.format(LAST_SEEN_FORMAT) + )?; + match self.control { + None => write!(f, "unknown)"), + Some(dt) => write!(f, "{})", dt.format(LAST_SEEN_FORMAT)), + } + } +} + +impl NodeLastSeen { + /// Construct a new `NodeLastSeen` with an optional "last seen by control" timestamp. The + /// `estimated` timestamp is always set to the current UTC date/time. + pub fn new(control: Option>) -> Self { + Self { + control, + ..Default::default() + } + } +} + +/// Whether a node is online (connected to the control plane) or offline (disconnected from the +/// control plane). +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash)] +pub enum NodeStatus { + /// The node may be online or offline; the control plane hasn't informed us yet. + #[default] + Unknown, + /// The node is online (connected to the control plane). + Online, + /// The node is offline (disconnected from the control plane). + Offline(NodeLastSeen), +} + +impl fmt::Display for NodeStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NodeStatus::Unknown => write!(f, "unknown"), + NodeStatus::Online => write!(f, "online"), + NodeStatus::Offline(nls) => write!(f, "offline, {nls}"), + } + } +} + +impl NodeStatus { + /// Construct a new `NodeStatus` from a combination of online and last-seen timestamp. + /// + /// The netmap messages we get from the control plane have multiple ways to indicate a peer is + /// online and/or the timestamp the peer was last connected to the control plane. This method + /// wraps the sometimes-painful logic of creating a `NodeStatus` from the various combinations + /// of netmap field values. + pub fn new(online: Option, last_seen: Option>) -> Self { + match (online, last_seen) { + (Some(true), None) => Self::Online, + (Some(false), None) => Self::Offline(NodeLastSeen::new(None)), + (Some(false), Some(dt)) | (None, Some(dt)) => { + Self::Offline(NodeLastSeen::new(Some(dt))) + } + (None, None) => Self::Unknown, + (online, last_seen) => { + tracing::warn!( + ?online, + ?last_seen, + "unexpected combination of online/last_seen states" + ); + Self::Unknown + } + } + } +} + /// A node in a tailnet. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Node { @@ -17,13 +146,24 @@ pub struct Node { pub id: Id, /// The node's stable id. pub stable_id: StableId, - - /// This node's hostname. + /// The node's hostname. pub hostname: String, + /// The node's capability version. + pub capability_version: CapabilityVersion, + + /// Whether the node is connected to the control plane or not. If [`NodeStatus::Unknown`], the + /// control plane hasn't told us yet. + /// + /// This field does _not_ indicate whether the node is reachable or visible from this device. + pub status: NodeStatus, + /// The tailnet this node belongs to. pub tailnet: Option, + /// The node capabilities assigned to this node. + pub node_capabilities: BTreeMap>, + /// The tags assigned to this node. pub tags: Vec, @@ -39,6 +179,9 @@ pub struct Node { pub machine_key: Option, /// The node's [`DiscoPublicKey`], if known. pub disco_key: Option, + /// The signature of the node's public key with the Tailnet Lock signing key, if Tailnet Lock + /// is enabled and the signature is known. + pub tailnet_lock_key_signature: Option>, /// The routes this node accepts traffic for. pub accepted_routes: Vec, @@ -50,6 +193,61 @@ pub struct Node { } impl Node { + /// Apply the given partial update to this `Node`. + /// + /// # Panics + /// If `update.id` does not match `self.id`. The caller must guarantee this method is only + /// called with [`NodeUpdate`]s for this `Node`. + #[tracing::instrument(skip_all, fields(id = self.stable_id.0, hostname = self.hostname))] + pub fn apply_update(&mut self, update: &NodeUpdate) { + assert_eq!(self.id, update.id, "node update ID != node ID"); + + if update.status != NodeStatus::Unknown { + tracing::debug!(old_status = %self.status, "peer {}", update.status); + self.status = update.status; + } + + if let Some(cap) = update.cap { + tracing::debug!(old=?self.capability_version, new=?cap, "updating capability version"); + self.capability_version = cap; + } + + if let Some(cap_map) = update.cap_map.as_ref() { + tracing::debug!(old=?self.node_capabilities, new=?cap_map, "updating node capabilities"); + self.node_capabilities = cap_map.clone(); + } + + if let Some(derp_region) = update.derp_region { + tracing::debug!(old=?self.derp_region, new=?derp_region, "updating derp home region"); + self.derp_region = Some(derp_region); + } + + if let Some(disco_key) = update.disco_key { + tracing::debug!(old=?self.disco_key, new=?disco_key, "updating disco key"); + self.disco_key = Some(disco_key); + } + + if let Some(node_key) = update.node_key { + tracing::debug!(old=?self.node_key, new=?node_key, "updating node key"); + self.node_key = node_key; + } + + if let Some(node_key_expiry) = update.node_key_expiry { + tracing::debug!(old=?self.node_key_expiry, new=?node_key_expiry, "updating node key expiry"); + self.node_key_expiry = Some(node_key_expiry); + } + + if let Some(tl_sig) = &update.tailnet_lock_key_signature { + tracing::debug!(old=?self.tailnet_lock_key_signature, new=?tl_sig, "updating tailnet lock key signature"); + self.tailnet_lock_key_signature = Some(tl_sig.clone()); + } + + if let Some(underlay_addresses) = &update.underlay_addresses { + tracing::debug!(old=?self.underlay_addresses, new=?underlay_addresses, "updating underlay addresses"); + self.underlay_addresses = underlay_addresses.clone(); + } + } + /// The fully-qualified domain name of the node. /// /// This is a string of the form `$HOST.$TAILNET_DOMAIN.`. For tailnets controlled by @@ -135,10 +333,18 @@ impl From<&ts_control_serde::Node<'_>> for Node { Self { id: value.id, stable_id: StableId(value.stable_id.0.to_string()), - hostname: hostname.to_owned(), + + capability_version: value.cap, + + status: NodeStatus::new(value.online, value.last_seen), tailnet, + node_capabilities: value + .cap_map + .iter() + .map(|(k, v)| (k.to_string(), v.into())) + .collect(), tags: value .tags .as_ref() @@ -153,6 +359,7 @@ impl From<&ts_control_serde::Node<'_>> for Node { node_key_expiry: value.key_expiry, machine_key: value.machine, disco_key: value.disco_key, + tailnet_lock_key_signature: value.key_signature.as_ref().map(|s| Vec::::from(*s)), accepted_routes: value .allowed_ips @@ -170,3 +377,73 @@ impl From<&ts_control_serde::Node<'_>> for Node { } } } + +/// A partial update to a [`Node`] in a tailnet. +/// +/// Devices should reject any `NodeUpdate` that it doesn't have a corresponding [`Node`] for. This +/// type combines multiple different update/patch-style fields from netmap messages into a single +/// type to simplify update handling for interested components, such as the peer tracker. +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub struct NodeUpdate { + /// The node's id. + pub id: Id, + + /// Whether this node is online or offline (with last-seen timestamp), according to the control + /// plane. If [`NodeStatus::Unknown`], has not changed. + pub status: NodeStatus, + + /// The DERP region for this node. If `None`, has not changed. + pub derp_region: Option, + + /// The node's capability version. If `None`, has not changed. + pub cap: Option, + /// The node's capabilities (node caps, not peer caps). If `None`, has not changed. + pub cap_map: Option>>, + + /// The node's [`NodePublicKey`]. If `None`, has not changed. + pub node_key: Option, + /// The node key's expiration. If `None`, has not changed. + pub node_key_expiry: Option>, + + /// The node's [`DiscoPublicKey`]. If `None`, has not changed. + pub disco_key: Option, + + /// The node's key signature for Tailnet Lock. If `None`, has not changed. + pub tailnet_lock_key_signature: Option>, + + /// The underlay addresses this node is reachable on (`Endpoints` in Go). If `None`, has not + /// changed. + pub underlay_addresses: Option>, +} + +impl From<&ts_control_serde::PeerChange<'_>> for NodeUpdate { + fn from(value: &ts_control_serde::PeerChange<'_>) -> Self { + let cap_map = value.cap_map.as_ref().map(|m| { + m.iter() + .map(|(name, values)| { + ( + String::from(*name), + values + .0 + .iter() + .map(|v| v.to_string()) + .collect::>(), + ) + }) + .collect() + }); + + Self { + id: value.node_id, + status: NodeStatus::new(value.online, value.last_seen), + derp_region: value.derp_region.map(|x| ts_derp::RegionId(x.into())), + cap: value.cap, + cap_map, + node_key: value.key, + node_key_expiry: value.key_expiry, + disco_key: value.disco_key, + tailnet_lock_key_signature: value.key_signature.map(|x| x.into()), + underlay_addresses: value.endpoints.clone(), + } + } +} diff --git a/ts_control/src/tokio/map_stream.rs b/ts_control/src/tokio/map_stream.rs index d83cbd78..d0cfd7a1 100644 --- a/ts_control/src/tokio/map_stream.rs +++ b/ts_control/src/tokio/map_stream.rs @@ -10,7 +10,7 @@ use ts_packetfilter as pf; use ts_packetfilter_state as pf_state; use url::Url; -use crate::{DialPlan, NodeId}; +use crate::{DialPlan, NodeId, NodeStatus}; #[derive(Debug, thiserror::Error, Clone, Copy, Eq, PartialEq)] pub enum MapStreamError { @@ -66,7 +66,9 @@ pub enum PeerUpdate { /// Delta update to the peer state. Delta { - /// Peers added to or changed in the state. + /// Peers with a few changed fields in the state. + patch: Vec, + /// Peers added to or completely changed in the state. upsert: Vec, /// Peer [`NodeId`]s removed from the state. remove: Vec, @@ -134,8 +136,53 @@ pub fn map_stream(reader: impl AsyncRead + Unpin) -> impl Stream = BTreeMap::new(); + for (id, seen) in map_response.peer_seen_change { + let status = if seen { + // Not online, and no timestamp provided by control, so we have to estimate. + NodeStatus::new(Some(false), None) + } else { + // We don't know whether the node is online or offline from this field. + NodeStatus::Unknown + }; + updates + .entry(id) + .and_modify(|u| u.status = status) + .or_insert(crate::NodeUpdate { + id, + status, + ..Default::default() + }); + } + + for (id, online) in map_response.online_change { + let status = NodeStatus::new(Some(online), None); + updates + .entry(id) + .and_modify(|u| u.status = status) + .or_insert(crate::NodeUpdate { + id, + status, + ..Default::default() + }); + } + + let mut patches = map_response + .peers_changed_patch + .unwrap_or_default() + .iter() + .map(|x| (x.node_id, crate::NodeUpdate::from(x))) + .collect(); + updates.append(&mut patches); + Some(PeerUpdate::Delta { + patch: updates.values().cloned().collect(), remove: map_response.peers_removed.unwrap_or_default(), upsert: map_response .peers_changed @@ -148,10 +195,6 @@ pub fn map_stream(reader: impl AsyncRead + Unpin) -> impl Stream { /// - Windows: "10.0.19044.1889" pub os_version: &'a str, - /// Indicates whether or not this Tailscale node is running inside a container. Detection is - /// best-effort only, and may not be accurate. + /// Indicates whether this Tailscale node is running inside a container. Detection is best- + /// effort only, and may not be accurate. pub container: Option, /// Represents the type of runtime environment that this Tailscale node is running in. #[serde(skip_serializing_if = "crate::util::is_default")] @@ -56,10 +56,10 @@ pub struct HostInfo<'a> { /// Disambiguates Tailscale nodes that run using `tsnet` (e.g. "k8s-operator", "golinks", etc). pub app: &'a str, - /// Indicates whether or not a desktop environment was detected. Used only for Linux devices. + /// Indicates whether a desktop environment was detected. Used only for Linux devices. pub desktop: Option, /// How this Tailscale node was packaged/delivered to the device (e.g. "choco", "appstore", - /// etc). Empty string if the packaging mechanism is unknown. + /// etc.) Empty string if the packaging mechanism is unknown. pub package: &'a str, /// Model of mobile phone for mobile devices (e.g. "Pixel 3a", "iPhone12,3"). pub device_model: &'a str, @@ -69,7 +69,7 @@ pub struct HostInfo<'a> { /// Hostname of this Tailscale node's host. pub hostname: Option<&'a str>, - /// Indicates whether or not this Tailscale node's host is blocking incoming connections. + /// Indicates whether this Tailscale node's host is blocking incoming connections. pub shields_up: bool, /// Indicates this Tailscale node exists in the netmap because it's owned by a shared-to user. pub sharee_node: bool, @@ -83,7 +83,7 @@ pub struct HostInfo<'a> { /// optimization, this is only sent if [`HostInfo::ingress_enabled`] is `false`, as /// [`HostInfo::ingress_enabled`] implies that this option is `true`. pub wire_ingress: bool, - /// Indicates whether or not this Tailscale node has any Tailscale Funnel endpoints enabled. + /// Indicates whether this Tailscale node has any Tailscale Funnel endpoints enabled. pub ingress_enabled: bool, /// Indicates that this Tailscale node has opted-in to remote updates triggered by the admin /// console. @@ -143,7 +143,8 @@ pub struct HostInfo<'a> { /// TPM device metadata, if available. pub tpm: Option>, - /// Reports whether the node state is stored encrypted on-disk. The actual mechanism is platform-specific: + /// Reports whether the node state is stored encrypted on-disk. The actual mechanism is + /// platform-specific: /// * Apple nodes use the Keychain /// * Linux and Windows nodes use the TPM /// * Android apps use `EncryptedSharedPreferences` diff --git a/ts_control_serde/src/lib.rs b/ts_control_serde/src/lib.rs index c45dd99f..21bddec9 100644 --- a/ts_control_serde/src/lib.rs +++ b/ts_control_serde/src/lib.rs @@ -36,7 +36,7 @@ pub use dns::{ }; pub use host_info::HostInfo; pub use net_info::{DerpLatencyMap, LinkType, NetInfo}; -pub use netmap::{Endpoint, EndpointType, MapRequest, MapResponse}; +pub use netmap::{Endpoint, EndpointType, MapRequest, MapResponse, PeerChange}; pub use node::{MarshaledSignature, Node, NodeId, StableNodeId}; pub use ping::{PingRequest, PingResponse, PingType}; pub use register::{RegisterAuth, RegisterRequest, RegisterResponse, SignatureType}; diff --git a/ts_control_serde/src/netmap.rs b/ts_control_serde/src/netmap.rs index e67fbf80..c2efa51f 100644 --- a/ts_control_serde/src/netmap.rs +++ b/ts_control_serde/src/netmap.rs @@ -313,7 +313,7 @@ pub struct MapResponse<'a> { /// These are applied after `peers*`, but in practice, the control server should only /// send these on their own, without the `peers*` fields also set. #[serde(borrow)] - pub peers_changed_patch: Vec>>, + pub peers_changed_patch: Option>>, /// How to update peers' [`last_seen`][crate::Node::last_seen] times. /// diff --git a/ts_control_serde/src/node.rs b/ts_control_serde/src/node.rs index 8a871678..51e0e0d7 100644 --- a/ts_control_serde/src/node.rs +++ b/ts_control_serde/src/node.rs @@ -70,7 +70,7 @@ pub struct Node<'a> { /// If populated, a signature of the Tailnet Key Authority (TKA) key authorizing the Tailscale /// node to join the Tailnet. #[serde(borrow)] - pub key_signature: MarshaledSignature<'a>, + pub key_signature: Option>, /// If populated, the public key of the Tailscale node's [`MachineKeyPair`][ts_keys::MachineKeyPair]. pub machine: Option, /// If populated, the public key of the Tailscale node's [`DiscoKeyPair`][ts_keys::DiscoKeyPair]. diff --git a/ts_derp/src/lib.rs b/ts_derp/src/lib.rs index 654ca8ce..968dedf2 100644 --- a/ts_derp/src/lib.rs +++ b/ts_derp/src/lib.rs @@ -1,7 +1,7 @@ #![doc = include_str!("../README.md")] use core::{ - fmt::Formatter, + fmt, net::{Ipv4Addr, Ipv6Addr}, num::NonZeroU32, }; @@ -18,9 +18,18 @@ pub use error::Error; /// A 24-byte nonce for symmetric encryption with ChaCha20Poly1305. #[repr(C)] -#[derive(Debug, Copy, Clone, PartialEq, KnownLayout, Immutable, IntoBytes, FromBytes)] +#[derive(Copy, Clone, PartialEq, KnownLayout, Immutable, IntoBytes, FromBytes)] pub struct Nonce(pub [u8; 24]); +impl fmt::Debug for Nonce { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for b in self.0.iter() { + write!(f, "{b:02x}")?; + } + Ok(()) + } +} + impl From> for Nonce { fn from(value: crypto_box::aead::Nonce) -> Self { Nonce(value.into()) @@ -37,8 +46,8 @@ impl From for crypto_box::aead::Nonce { #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct RegionId(pub NonZeroU32); -impl core::fmt::Display for RegionId { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for RegionId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.get().fmt(f) } } @@ -100,7 +109,7 @@ impl ServerConnInfo { /// /// - The URL uses the `https` scheme /// - The URL's host resolves to IPv4 and IPv6 addresses via DNS - /// - The TLS common name is the url's host + /// - The TLS common name is the URL's host /// - The STUN port for the server is the default (3478) /// - The server doesn't support port 80 connections for captive portal detection pub fn default_from_url(url: &url::Url) -> Option { diff --git a/ts_nodecapability/src/lib.rs b/ts_nodecapability/src/lib.rs index ac60fd61..0790c779 100644 --- a/ts_nodecapability/src/lib.rs +++ b/ts_nodecapability/src/lib.rs @@ -3,7 +3,11 @@ extern crate alloc; -use alloc::{collections::BTreeMap, vec::Vec}; +use alloc::{ + collections::BTreeMap, + string::{String, ToString}, + vec::Vec, +}; /// A map of node capabilities to their optional values. It is valid for a capability to have /// `None` as a value; such capabilities can be tested for by using the @@ -52,6 +56,12 @@ pub struct Values<'a>( pub Vec>, ); +impl From<&Values<'_>> for Vec { + fn from(value: &Values<'_>) -> Self { + value.0.iter().map(|v| v.to_string()).collect() + } +} + #[cfg(feature = "serde")] fn deserialize_nodecap<'a, 'de, D>( deserializer: D, diff --git a/ts_runtime/Cargo.toml b/ts_runtime/Cargo.toml index 1b9313b5..ee13be84 100644 --- a/ts_runtime/Cargo.toml +++ b/ts_runtime/Cargo.toml @@ -38,9 +38,11 @@ tracing.workspace = true smallvec.workspace = true [dev-dependencies] -rand = "0.10" -proptest = "1.11" -itertools = "0.14" +chrono.workspace = true +itertools.workspace = true +proptest.workspace = true +rand.workspace = true +ts_capabilityversion.workspace = true [lints] workspace = true diff --git a/ts_runtime/src/peer_tracker/mod.rs b/ts_runtime/src/peer_tracker/mod.rs index ce8584be..621eb77c 100644 --- a/ts_runtime/src/peer_tracker/mod.rs +++ b/ts_runtime/src/peer_tracker/mod.rs @@ -215,15 +215,13 @@ impl Message> for PeerTracker { } } - ts_control::PeerUpdate::Delta { remove, upsert } => { + ts_control::PeerUpdate::Delta { + patch, + remove, + upsert, + } => { tracing::trace!("delta peer update"); - for peer in upsert { - let id = self.peer_db.upsert(peer); - - upserts.insert(id); - } - for peer in remove { let Some((id, _node)) = self.peer_db.remove(peer) else { tracing::error!(control_node_id = peer, "removed peer was unknown"); @@ -232,6 +230,18 @@ impl Message> for PeerTracker { deletions.insert(id); } + + for peer in upsert { + let id = self.peer_db.upsert(peer); + + upserts.insert(id); + } + + for update in patch { + let id = self.peer_db.patch(update); + + upserts.insert(id); + } } } diff --git a/ts_runtime/src/peer_tracker/peer_db.rs b/ts_runtime/src/peer_tracker/peer_db.rs index 076c5e4a..824c5e02 100644 --- a/ts_runtime/src/peer_tracker/peer_db.rs +++ b/ts_runtime/src/peer_tracker/peer_db.rs @@ -6,7 +6,7 @@ use std::{ }; use ts_bart::{RouteModification, RoutingTable, RoutingTableExt}; -use ts_control::{Node, StableNodeId}; +use ts_control::{Node, NodeUpdate, StableNodeId}; use ts_keys::{DiscoPublicKey, NodePublicKey}; use ts_transport::PeerId; @@ -82,6 +82,21 @@ struct IndexState { } impl PeerDb { + /// Apply an update to a node in the peer db. + /// + /// The [`NodeUpdate::id`] field is used as the primary key to identify the node. + /// + /// # Panics + /// If the peer db does not contain a node where [`Node::id`] == [`NodeUpdate::id`]. + pub fn patch(&mut self, update: &NodeUpdate) -> PeerId { + let id = (update.id) + .lookup(self) + .expect("no matching peer for update"); + let mut peer = self.peers.remove(&id).unwrap(); + peer.apply_update(update); + self.upsert(&peer) + } + /// Upsert a node into the peer db. /// /// The [`StableNodeId`] is used as the primary key to identify the node. @@ -446,21 +461,23 @@ impl IndexedField for IpAddr { #[cfg(test)] mod test { use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet}, net::{Ipv4Addr, Ipv6Addr, SocketAddr}, num::NonZeroU32, }; + use chrono::DateTime; use proptest::{ collection::{hash_set, vec}, - prelude::any, + prelude::{Just, any}, strategy::Strategy, }; use rand::{ RngExt, distr::{Alphanumeric, SampleString}, }; - use ts_control::TailnetAddress; + use ts_capabilityversion::CapabilityVersion; + use ts_control::{NodeLastSeen, NodeStatus, TailnetAddress}; use super::*; @@ -516,9 +533,12 @@ mod test { hostname: rand_string(&mut rng, 32), tailnet: rng.random::().then_some(rand_string(&mut rng, 32)), + capability_version: Default::default(), + status: NodeStatus::Unknown, node_key_expiry: None, underlay_addresses: vec![], + tailnet_lock_key_signature: None, derp_region: rng .random::() .then_some(ts_derp::RegionId(rng.random())), @@ -526,6 +546,7 @@ mod test { tags: (0..rng.random_range(0..8)) .map(|_| rand_string(&mut rng, 32)) .collect(), + node_capabilities: Default::default(), } } @@ -653,6 +674,26 @@ mod test { assert_eq!(node, &node2); } + proptest::prop_compose! { + fn capability_version_low()( + capver in 3u16..34, + ) -> CapabilityVersion { + CapabilityVersion::new(capver).unwrap() + } + } + + proptest::prop_compose! { + fn capability_version_high()( + capver in 36u16..133, + ) -> CapabilityVersion { + CapabilityVersion::new(capver).unwrap() + } + } + + fn capability_version() -> impl Strategy { + proptest::prop_oneof![capability_version_low(), capability_version_high()] + } + proptest::prop_compose! { fn ipv4net()( addr: Ipv4Addr, @@ -678,6 +719,26 @@ mod test { ] } + proptest::prop_compose! { + fn last_seen()( + control_ns_since_epoch in any::>(), + estimated_ns_since_epoch in any::(), + ) -> NodeLastSeen { + NodeLastSeen { + control: control_ns_since_epoch.map(DateTime::from_timestamp_nanos), + estimated: DateTime::from_timestamp_nanos(estimated_ns_since_epoch), + } + } + } + + fn status() -> impl Strategy { + proptest::prop_oneof![ + Just(NodeStatus::Unknown), + Just(NodeStatus::Online), + last_seen().prop_map(NodeStatus::Offline), + ] + } + proptest::prop_compose! { fn domain_segment()( seg in "[[:alpha:]][[:alnum:]]*" @@ -694,7 +755,17 @@ mod test { } } + proptest::prop_compose! { + fn node_capabilities(max_values_per_entry: usize)( + keys in hash_set(".+", 0..32), + values in vec(vec(".+", 0..32), 0..max_values_per_entry), + ) -> BTreeMap> { + BTreeMap::from_iter(keys.into_iter().zip(values.into_iter())) + } + } + type Key = [u8; 32]; + type TailnetLockSignature = [u8; 32]; proptest::prop_compose! { // This is set up this way to ensure uniqueness among all the required-unique keys in a @@ -702,11 +773,15 @@ mod test { fn nodes(n: usize)( id in hash_set(any::(), n), stable_id in hash_set(".+", n), + capability_version in vec(capability_version(), n), tags in vec(hash_set(".+", 0..32), n), + node_capabilities in vec(node_capabilities(3), n), accepted_routes in vec(hash_set(ipnet(), 0..32), n), + status in vec(status(), n), node_key in hash_set(any::(), n), machine_key in vec(any::>(), n), disco_key in vec(any::>(), n), + tailnet_lock_key_signature in vec(any::>(), n), ipv4 in hash_set(any::(), n), ipv6 in hash_set(any::(), n), name in hash_set(domain_segment(), n), @@ -718,11 +793,15 @@ mod test { itertools::izip![ id, stable_id, + capability_version, tags, + node_capabilities, accepted_routes, + status, node_key, machine_key, disco_key, + tailnet_lock_key_signature, ipv4, ipv6, name, @@ -733,11 +812,15 @@ mod test { ].map(|( id, stable_id, + capability_version, tags, + node_capabilities, mut accepted_routes, + status, node_key, machine_key, disco_key, + tailnet_lock_key_signature, ipv4, ipv6, name, @@ -752,13 +835,16 @@ mod test { Node { id, stable_id: StableNodeId(stable_id), + capability_version, hostname: name, tailnet: has_tailnet.then_some(tailnet), + status, node_key: node_key.into(), disco_key: disco_key.map(Into::into), machine_key: machine_key.map(Into::into), + tailnet_lock_key_signature: tailnet_lock_key_signature.map(Into::into), node_key_expiry: None, @@ -767,6 +853,7 @@ mod test { ipv6: ipv6.into(), }, tags: tags.into_iter().collect(), + node_capabilities, derp_region: derp_region.map(ts_derp::RegionId),