diff --git a/Cargo.lock b/Cargo.lock index 25e2ef7d..87845dcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4503,6 +4503,7 @@ name = "ts_runtime" version = "0.2.0" dependencies = [ "futures", + "ipnet", "kameo", "kameo_actors", "thiserror 2.0.18", diff --git a/src/lib.rs b/src/lib.rs index 9c942e5b..921a0115 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -235,6 +235,16 @@ impl Device { .map_err(Into::into) } + /// Get our node info. + pub async fn self_node(&self) -> Result { + self.runtime + .control + .ask(ts_runtime::control_runner::SelfNode) + .await + .map_err(ts_runtime::Error::from)? + .ok_or(Error::RuntimeDegraded) + } + /// Look up a peer by name. pub async fn peer_by_name(&self, name: &str) -> Result, Error> { let pt = self @@ -251,14 +261,32 @@ impl Device { .map_err(Into::into) } - /// Get our node info. - pub async fn self_node(&self) -> Result { - self.runtime - .control - .ask(ts_runtime::control_runner::SelfNode) + /// Look up a peer by ip. + pub async fn peer_by_tailnet_ip(&self, ip: IpAddr) -> Result, Error> { + let pt = self + .runtime + .peer_tracker + .upgrade() + .ok_or(Error::RuntimeDegraded)?; + + pt.ask(ts_runtime::peer_tracker::PeerByTailnetIp { ip }) .await - .map_err(ts_runtime::Error::from)? - .ok_or(Error::RuntimeDegraded) + .map_err(ts_runtime::Error::from) + .map_err(Into::into) + } + + /// Look up the peer(s) with the most-specific route matches for `ip`. + pub async fn peers_with_route(&self, ip: IpAddr) -> Result, Error> { + let pt = self + .runtime + .peer_tracker + .upgrade() + .ok_or(Error::RuntimeDegraded)?; + + pt.ask(ts_runtime::peer_tracker::PeerByAcceptedRoute { ip }) + .await + .map_err(ts_runtime::Error::from) + .map_err(Into::into) } /// Attempt to gracefully shut down this device's runtime. diff --git a/ts_elixir/lib/tailscale.ex b/ts_elixir/lib/tailscale.ex index 528cda3a..3582e87a 100644 --- a/ts_elixir/lib/tailscale.ex +++ b/ts_elixir/lib/tailscale.ex @@ -110,6 +110,12 @@ defmodule Tailscale do """ def ipv6_addr(dev), do: Tailscale.Native.ipv6_addr(dev) + @spec self_node(t()) :: {:ok, Tailscale.NodeInfo.t()} | {:error, any()} + @doc """ + Get this node's `m:Tailscale.NodeInfo`. + """ + defdelegate self_node(dev), to: Tailscale.Native + @spec peer_by_name(t(), String.t()) :: {:ok, Tailscale.NodeInfo.t() | nil} | {:error, any()} @doc """ Look up a peer by name. @@ -119,9 +125,19 @@ defmodule Tailscale do """ def peer_by_name(dev, name), do: Tailscale.Native.peer_by_name(dev, name) - @spec self_node(t()) :: {:ok, Tailscale.NodeInfo.t()} | {:error, any()} + @spec peer_by_tailnet_ip(t(), Tailscale.ip_addr()) :: + {:ok, Tailscale.NodeInfo.t() | nil} | {:error, any()} @doc """ - Get this node's `m:Tailscale.NodeInfo`. + Look up the peer with the given tailnet IP address. + + Returns `{:ok, nil}` if there was no such peer. `:error` if the lookup encountered an error. """ - defdelegate self_node(dev), to: Tailscale.Native + defdelegate peer_by_tailnet_ip(dev, ip), to: Tailscale.Native + + @spec peers_with_route(t(), Tailscale.ip_addr()) :: + {:ok, [Tailscale.NodeInfo.t()]} | {:error, any()} + @doc """ + Retrieve the most narrow set of peers that accept packets for the specified IP. + """ + defdelegate peers_with_route(dev, ip), to: Tailscale.Native end diff --git a/ts_elixir/lib/tailscale/native.ex b/ts_elixir/lib/tailscale/native.ex index 5efbdb5f..abd7dfdd 100644 --- a/ts_elixir/lib/tailscale/native.ex +++ b/ts_elixir/lib/tailscale/native.ex @@ -169,6 +169,18 @@ defmodule Tailscale.Native do @spec self_node(device()) :: {:ok, %{}} | {:error, any()} def self_node(_dev), do: err() + @doc """ + Retrieve a peer by its tailnet IP. + """ + @spec peer_by_tailnet_ip(device(), Tailscale.ip_addr()) :: {:ok, %{} | nil} | {:error, any()} + def peer_by_tailnet_ip(_dev, _ip), do: err() + + @doc """ + Retrieve the most narrow set of peers that accept packets for the specified IP. + """ + @spec peers_with_route(device(), Tailscale.ip_addr()) :: {:ok, [%{}]} | {:error, any()} + def peers_with_route(_dev, _ip), do: err() + @doc """ Load key state from the specified path, generating a new state if the file doesn't exist. """ diff --git a/ts_elixir/native/ts_elixir/src/lib.rs b/ts_elixir/native/ts_elixir/src/lib.rs index 7cc78f0e..8953dc2f 100644 --- a/ts_elixir/native/ts_elixir/src/lib.rs +++ b/ts_elixir/native/ts_elixir/src/lib.rs @@ -177,6 +177,40 @@ fn self_node(env: rustler::Env<'_>, dev: ResourceArc) -> impl Encoder { } } +#[rustler::nif(schedule = "DirtyIo")] +fn peer_by_tailnet_ip(env: rustler::Env<'_>, dev: ResourceArc, ip: Term) -> impl Encoder { + let dev = dev.inner.clone(); + let Some(ip) = ip_from_erl(ip) else { + return env.error_tuple("invalid ip"); + }; + + match TOKIO_RUNTIME.block_on(async move { dev.peer_by_tailnet_ip(ip).await }) { + Err(e) => (atoms::error(), e.to_string()).encode(env), + Ok(None) => (atoms::ok(), Option::<()>::None).encode(env), + Ok(Some(peer)) => (atoms::ok(), NodeInfo::from_node(env, peer)).encode(env), + } +} + +#[rustler::nif(schedule = "DirtyIo")] +fn peers_with_route(env: rustler::Env<'_>, dev: ResourceArc, ip: Term) -> impl Encoder { + let dev = dev.inner.clone(); + let Some(ip) = ip_from_erl(ip) else { + return env.error_tuple("invalid ip"); + }; + + match TOKIO_RUNTIME.block_on(async move { dev.peers_with_route(ip).await }) { + Err(e) => (atoms::error(), e.to_string()).encode(env), + Ok(peers) => ( + atoms::ok(), + peers + .into_iter() + .map(|x| NodeInfo::from_node(env, x)) + .collect::>(), + ) + .encode(env), + } +} + fn ip_to_erl(env: rustler::Env, ip: impl Into) -> Term { match ip.into() { IpAddr::V4(ip) => { diff --git a/ts_python/src/lib.rs b/ts_python/src/lib.rs index dcd8e325..00c60bee 100644 --- a/ts_python/src/lib.rs +++ b/ts_python/src/lib.rs @@ -206,6 +206,36 @@ impl Device { Ok(NodeInfo::from(&node)) }) } + + /// Look up a peer by its tailnet IP address. + pub fn peer_by_tailnet_ip<'p>(&self, py: Python<'p>, ip: IpRepr) -> PyFut<'p> { + let dev = self.dev.clone(); + + future_into_py(py, async move { + let ip = ip.try_into().map_err(py_value_err)?; + let node = dev.peer_by_tailnet_ip(ip).await.map_err(py_value_err)?; + + Ok(node.map(|node| NodeInfo::from(&node))) + }) + } + + /// Look up peer(s) with the most specific route match for the given address. + /// + /// If more than one peer has the same route covering the same address, more than one + /// result may be returned. + pub fn peers_with_route<'p>(&self, py: Python<'p>, ip: IpRepr) -> PyFut<'p> { + let dev = self.dev.clone(); + + future_into_py(py, async move { + let ip = ip.try_into().map_err(py_value_err)?; + let nodes = dev.peers_with_route(ip).await.map_err(py_value_err)?; + + Ok(nodes + .into_iter() + .map(|node| NodeInfo::from(&node)) + .collect::>()) + }) + } } fn sockaddr_as_tuple(s: SocketAddr) -> (IpAddr, u16) { diff --git a/ts_runtime/Cargo.toml b/ts_runtime/Cargo.toml index 2b5ce942..f9929bbf 100644 --- a/ts_runtime/Cargo.toml +++ b/ts_runtime/Cargo.toml @@ -28,6 +28,7 @@ ts_transport_derp.workspace = true # Unconditionally required dependencies. futures.workspace = true +ipnet.workspace = true kameo = "0.19" kameo_actors = "0.4" thiserror.workspace = true diff --git a/ts_runtime/src/peer_tracker.rs b/ts_runtime/src/peer_tracker.rs index 0b1afd48..8bc0b03d 100644 --- a/ts_runtime/src/peer_tracker.rs +++ b/ts_runtime/src/peer_tracker.rs @@ -1,10 +1,13 @@ //! Peer delta update tracking. use std::{ + borrow::Borrow, collections::{HashMap, HashSet}, + net::IpAddr, sync::Arc, }; +use ipnet::IpNet; use kameo::{ actor::ActorRef, message::{Context, Message}, @@ -24,10 +27,18 @@ pub struct PeerTracker { env: Env, } +// TODO(npry): accelerate with indexed data structures, linear search won't be +// acceptable on large tailnets. impl PeerTracker { fn peer_by_name_opt(&self, name: &str) -> Option<&Node> { self.peers.values().find(|&peer| peer.matches_name(name)) } + + fn peer_by_tailnet_ip_opt(&self, ip: IpAddr) -> Option<&Node> { + self.peers.values().find(|&peer| { + peer.tailnet_address.ipv4.addr() == ip || peer.tailnet_address.ipv6.addr() == ip + }) + } } impl kameo::Actor for PeerTracker { @@ -49,6 +60,8 @@ impl kameo::Actor for PeerTracker { enum Pending { PeerByName(PeerByName, ReplySender>), + AcceptedRoute(PeerByAcceptedRoute, ReplySender>), + TailnetIp(PeerByTailnetIp, ReplySender>), } // For messages with arguments, a struct is generated with the args as fields. They aren't @@ -56,6 +69,8 @@ enum Pending { // docs are turned off everywhere. #[allow(missing_docs)] mod msg_impl { + use std::net::IpAddr; + use kameo::prelude::DelegatedReply; use super::*; @@ -87,6 +102,67 @@ mod msg_impl { deleg } + + /// Lookup all peers that accept packets addressed to the given IP. + /// + /// This includes the peer's tailnet address and any subnet routes it provides. Only + /// the peers with the most specific subnet route match that covers `ip` will be + /// returned. + /// + /// E.g., suppose: + /// + /// - We're querying for `10.1.2.3` + /// - `PeerA` and `PeerB` have accepted routes for `10.1.2.0/24` + /// - `PeerC` has an accepted route for `10.1.0.0/16` + /// + /// Only `PeerA` and `PeerB` will be returned, since they have the most specific + /// prefix match. + #[message(ctx)] + pub fn peer_by_accepted_route( + &mut self, + ctx: &mut Context>>, + ip: IpAddr, + ) -> DelegatedReply> { + let (deleg, sender) = ctx.reply_sender(); + let Some(sender) = sender else { return deleg }; + + if !self.seen_state_update { + tracing::debug!(query = %ip, "no peer state seen yet, queueing request"); + + self.pending_requests + .push(Pending::AcceptedRoute(PeerByAcceptedRoute { ip }, sender)); + + return deleg; + } + + sender.send(best_route_match(ip, self.peers.values())); + + deleg + } + + /// Lookup the peer that has the given tailnet IP address. + #[message(ctx)] + pub fn peer_by_tailnet_ip( + &mut self, + ctx: &mut Context>>, + ip: IpAddr, + ) -> DelegatedReply> { + let (deleg, sender) = ctx.reply_sender(); + let Some(sender) = sender else { return deleg }; + + if !self.seen_state_update { + tracing::debug!(query = %ip, "no peer state seen yet, queueing request"); + + self.pending_requests + .push(Pending::TailnetIp(PeerByTailnetIp { ip }, sender)); + + return deleg; + } + + sender.send(self.peer_by_tailnet_ip_opt(ip).cloned()); + + deleg + } } } @@ -179,6 +255,12 @@ impl Message> for PeerTracker { Pending::PeerByName(PeerByName { name }, reply) => { reply.send(self.peer_by_name_opt(&name).cloned()); } + Pending::TailnetIp(PeerByTailnetIp { ip }, reply) => { + reply.send(self.peer_by_tailnet_ip_opt(ip).cloned()); + } + Pending::AcceptedRoute(PeerByAcceptedRoute { ip }, reply) => { + reply.send(best_route_match(ip, self.peers.values())); + } } } } @@ -196,3 +278,159 @@ impl Message> for PeerTracker { } } } + +/// Get the most-narrow set of peers that have routes for the given IP. +fn best_route_match<'n, N>(query_ip: IpAddr, it: impl IntoIterator) -> Vec +where + N: Borrow + 'n, +{ + // TODO(npry): accelerate with an indexed data structure, linear search won't be + // acceptable on large tailnets. + + let (_, matching_peers) = it.into_iter().fold( + (None, vec![]), + |(mut best_match, mut matching_peers), peer: N| { + let peer = peer.borrow(); + let mut peer_best = None; + + for &candidate in &peer.accepted_routes { + // Normalize all prefixes to truncated form (mask off the host bits). + let candidate = candidate.trunc(); + + if !candidate.contains(&query_ip) { + continue; + } + + if peer_best + .as_ref() + .is_none_or(|existing: &IpNet| existing.contains(&candidate)) + { + peer_best = Some(candidate); + } + } + + match (best_match.as_ref(), peer_best) { + // This peer doesn't match, skip + (_, None) => return (best_match, matching_peers), + + // No previous match, set unconditionally + (None, _) => best_match = peer_best, + + // Previous match (same prefix), don't update + (Some(x), Some(y)) if x == &y => {} + + // New best match, clear old state + (Some(existing), Some(candidate)) if existing.contains(&candidate) => { + matching_peers.clear(); + best_match = peer_best; + } + + // This peer doesn't have as good a match + _ => return (best_match, matching_peers), + } + + matching_peers.push(peer.clone()); + + (best_match, matching_peers) + }, + ); + + matching_peers +} + +#[cfg(test)] +mod test { + use std::net::Ipv4Addr; + + use ipnet::Ipv4Net; + use ts_control::{StableNodeId, TailnetAddress}; + + use super::*; + + fn dummy_node(routes: impl IntoIterator) -> Node { + Node { + accepted_routes: routes.into_iter().collect(), + + node_key: Default::default(), + id: 0, + stable_id: StableNodeId("".to_owned()), + disco_key: Default::default(), + machine_key: None, + tailnet: None, + hostname: "".to_owned(), + tailnet_address: TailnetAddress { + ipv4: Default::default(), + ipv6: Default::default(), + }, + underlay_addresses: vec![], + node_key_expiry: None, + derp_region: None, + tags: vec![], + } + } + + fn ipv4net(ip: impl Into, pfx_len: usize) -> IpNet { + Ipv4Net::new(ip.into(), pfx_len as _).unwrap().into() + } + + #[test] + fn route_match() { + // no peers, no match + let m = best_route_match::([1, 2, 3, 4].into(), []); + assert!(m.is_empty()); + + // peer with no routes, no match + let m = best_route_match::([1, 2, 3, 4].into(), [dummy_node([])]); + assert!(m.is_empty()); + + // single peer, single match -- typical case + let m = best_route_match::( + [1, 2, 3, 4].into(), + [dummy_node([ipv4net([1, 2, 3, 4], 32)])], + ); + assert_eq!(m.len(), 1); + + // two matches both succeed + let m = best_route_match::( + [1, 2, 3, 4].into(), + [ + dummy_node([ipv4net([1, 2, 3, 4], 32)]), + dummy_node([ipv4net([1, 2, 3, 4], 32)]), + ], + ); + assert_eq!(m.len(), 2); + + // more-specific match wins + let m = best_route_match::( + [1, 2, 3, 4].into(), + [ + dummy_node([ipv4net([1, 2, 3, 4], 31)]), + dummy_node([ipv4net([1, 2, 3, 4], 32)]), + ], + ); + assert_eq!(m.len(), 1); + assert_eq!(m[0].accepted_routes[0].prefix_len(), 32); + + // denormalized prefix + let m = best_route_match::( + [1, 2, 3, 4].into(), + [ + dummy_node([ipv4net([1, 2, 3, 0], 24)]), + dummy_node([ipv4net([1, 2, 3, 8], 24)]), + ], + ); + assert_eq!(m.len(), 2); + assert_eq!(m[0].accepted_routes[0].prefix_len(), 24); + + // overlapping routes + let m = best_route_match::( + [1, 2, 3, 4].into(), + [ + dummy_node([ipv4net([1, 2, 3, 0], 24), ipv4net([1, 2, 3, 123], 24)]), + dummy_node([ipv4net([1, 2, 3, 8], 24)]), + ], + ); + assert_eq!(m.len(), 2); + assert_eq!(m[0].accepted_routes[0].prefix_len(), 24); + } +}