use std::collections::HashMap;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::RwLock as StdRwLock;
use tokio::net::TcpStream;
use tokio::sync::broadcast;
#[cfg(test)]
use tokio::sync::RwLock;
use crate::envelope::codec::{EnvelopeCodec, JsonCodec};
use crate::envelope::{Envelope, EnvelopeError};
use crate::file_transfer::{self, FileTransferState};
use crate::identity::{self, AppId, DeviceId, DeviceName};
use crate::network::tailscale::{TailscaleConfig, TailscaleProvider};
use crate::network::{DialOpts, HealthInfo, NetworkProvider, NodeIdentity, PingResult};
use crate::session::{PeerEvent, PeerRegistry, PeerState};
use crate::transport::quic::{QuicConnection, QuicListener};
use crate::transport::websocket::WebSocketTransport;
use crate::transport::{DatagramSocket, RawListener, WsConfig};
#[derive(Debug, Clone)]
pub struct NamespacedMessage {
pub from: String,
pub namespace: String,
pub msg_type: String,
pub payload: serde_json::Value,
pub timestamp: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct Peer {
pub id: String,
pub name: String,
pub device_id: String,
pub device_name: String,
pub tailscale_id: String,
pub ip: IpAddr,
pub online: bool,
pub ws_connected: bool,
pub connection_type: String,
pub os: Option<String>,
pub last_seen: Option<String>,
}
impl From<PeerState> for Peer {
fn from(s: PeerState) -> Self {
let (device_id, device_name, os) = match s.identity.as_ref() {
Some(identity) => (
identity.device_id.clone(),
identity.device_name.clone(),
Some(identity.os.clone()),
),
None => (s.id.clone(), s.name.clone(), s.os.clone()),
};
let legacy_id = s
.identity
.as_ref()
.map(|i| i.device_id.clone())
.unwrap_or_else(|| s.id.clone());
Self {
id: legacy_id,
name: s.name,
device_id,
device_name,
tailscale_id: s.id,
ip: s.ip,
online: s.online,
ws_connected: s.ws_connected,
connection_type: s.connection_type,
os,
last_seen: s.last_seen,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum NodeError {
#[error("peer not found: {0}")]
PeerNotFound(String),
#[error("connection failed: {0}")]
ConnectionFailed(String),
#[error("send failed: {0}")]
SendFailed(String),
#[error("envelope error: {0}")]
Envelope(#[from] EnvelopeError),
#[error("session error: {0}")]
Session(#[from] crate::session::SessionError),
#[error("network error: {0}")]
Network(#[from] crate::network::NetworkError),
#[error("transport error: {0}")]
Transport(#[from] crate::transport::TransportError),
#[error("not implemented: {0}")]
NotImplemented(String),
#[error("port {0} is reserved by truffle (443 = sidecar TLS, or the session WebSocket port)")]
ReservedPort(u16),
#[error("node stopped")]
Stopped,
#[error("build error: {0}")]
BuildError(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
pub struct Node<N: NetworkProvider + 'static> {
pub(crate) network: Arc<N>,
session: Arc<PeerRegistry<N>>,
codec: Arc<dyn EnvelopeCodec>,
#[allow(dead_code)]
incoming_tx: broadcast::Sender<NamespacedMessage>,
namespace_filters: Arc<StdRwLock<HashMap<String, broadcast::Sender<NamespacedMessage>>>>,
pub(crate) file_transfer_state: FileTransferState,
state_dir: PathBuf,
ws_port: u16,
pub(crate) proxy_state: crate::proxy::ProxyState,
stopped: std::sync::atomic::AtomicBool,
}
impl<N: NetworkProvider + 'static> Node<N> {
pub(crate) fn from_parts(
network: Arc<N>,
session: Arc<PeerRegistry<N>>,
codec: Arc<dyn EnvelopeCodec>,
) -> Self {
let (incoming_tx, _) = broadcast::channel(1024);
let namespace_filters: Arc<
StdRwLock<HashMap<String, broadcast::Sender<NamespacedMessage>>>,
> = Arc::new(StdRwLock::new(HashMap::new()));
let node = Self {
network,
session: session.clone(),
codec: codec.clone(),
incoming_tx: incoming_tx.clone(),
namespace_filters: namespace_filters.clone(),
file_transfer_state: FileTransferState::new(),
state_dir: PathBuf::new(),
ws_port: 9417,
proxy_state: crate::proxy::ProxyState::new(),
stopped: std::sync::atomic::AtomicBool::new(false),
};
node.spawn_envelope_router(session, codec, incoming_tx, namespace_filters);
node
}
fn spawn_envelope_router(
&self,
session: Arc<PeerRegistry<N>>,
codec: Arc<dyn EnvelopeCodec>,
incoming_tx: broadcast::Sender<NamespacedMessage>,
namespace_filters: Arc<StdRwLock<HashMap<String, broadcast::Sender<NamespacedMessage>>>>,
) {
let mut rx = session.subscribe();
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(msg) => {
if let Ok(envelope) = codec.decode(&msg.data) {
let namespaced = NamespacedMessage {
from: msg.from,
namespace: envelope.namespace.clone(),
msg_type: envelope.msg_type,
payload: envelope.payload,
timestamp: envelope.timestamp,
};
tracing::debug!(
from = %namespaced.from,
namespace = %namespaced.namespace,
msg_type = %namespaced.msg_type,
"envelope router: dispatching message"
);
let _ = incoming_tx.send(namespaced.clone());
let filters = namespace_filters
.read()
.expect("namespace_filters std RwLock poisoned");
let _has_subscriber = filters.contains_key(&namespaced.namespace);
if let Some(tx) = filters.get(&namespaced.namespace) {
let send_result = tx.send(namespaced);
tracing::debug!(
namespace = %envelope.namespace,
subscriber_count = tx.receiver_count(),
sent = send_result.is_ok(),
"envelope router: sent to namespace subscriber"
);
} else {
tracing::debug!(
namespace = %envelope.namespace,
"envelope router: no subscriber for namespace"
);
}
} else {
tracing::warn!(
from = %msg.from,
data_len = msg.data.len(),
"node: failed to decode envelope from incoming message"
);
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(
missed = n,
"node: envelope router lagged, missed {n} messages"
);
continue;
}
Err(broadcast::error::RecvError::Closed) => {
tracing::debug!("node: session incoming channel closed, router exiting");
break;
}
}
}
});
}
pub fn builder() -> NodeBuilder {
NodeBuilder::default()
}
pub fn file_transfer(&self) -> file_transfer::FileTransfer<'_, N> {
file_transfer::FileTransfer::new(self)
}
pub fn proxy(&self) -> crate::proxy::Proxy<'_, N> {
crate::proxy::Proxy::new(self)
}
pub fn synced_store<T>(
self: &Arc<Self>,
store_id: &str,
) -> Arc<crate::synced_store::SyncedStore<T>>
where
T: serde::Serialize + serde::de::DeserializeOwned + Clone + Send + Sync + 'static,
{
crate::synced_store::SyncedStore::new(self.clone(), store_id)
}
pub fn synced_store_with_backend<T>(
self: &Arc<Self>,
store_id: &str,
backend: std::sync::Arc<dyn crate::synced_store::StoreBackend>,
) -> Arc<crate::synced_store::SyncedStore<T>>
where
T: serde::Serialize + serde::de::DeserializeOwned + Clone + Send + Sync + 'static,
{
crate::synced_store::SyncedStore::new_with_backend(self.clone(), store_id, backend)
}
pub async fn crdt_doc(
self: &Arc<Self>,
doc_id: &str,
) -> Result<Arc<crate::crdt_doc::CrdtDoc>, crate::crdt_doc::CrdtDocError> {
crate::crdt_doc::CrdtDoc::new(self.clone(), doc_id).await
}
pub async fn crdt_doc_with_backend(
self: &Arc<Self>,
doc_id: &str,
backend: Arc<dyn crate::crdt_doc::CrdtBackend>,
) -> Result<Arc<crate::crdt_doc::CrdtDoc>, crate::crdt_doc::CrdtDocError> {
crate::crdt_doc::CrdtDoc::new_with_backend(self.clone(), doc_id, backend).await
}
pub(crate) fn with_state_dir(mut self, dir: PathBuf) -> Self {
self.state_dir = dir;
self
}
pub(crate) fn with_ws_port(mut self, port: u16) -> Self {
self.ws_port = port;
self
}
pub fn state_dir(&self) -> &Path {
&self.state_dir
}
pub async fn stop(&self) {
if self.stopped.swap(true, std::sync::atomic::Ordering::SeqCst) {
tracing::debug!("node: stop() called on already-stopped node");
return;
}
tracing::info!("node: stopping");
self.session.shutdown().await;
if let Err(e) = self.network.stop().await {
tracing::warn!(error = %e, "node: network provider stop failed");
}
tracing::info!("node: stopped");
}
pub fn local_info(&self) -> NodeIdentity {
self.network.local_identity()
}
pub async fn peers(&self) -> Vec<Peer> {
let app_id = self.network.local_identity().app_id;
self.session
.peers()
.await
.into_iter()
.map(|s| {
let bare = s
.identity
.is_none()
.then(|| hostname_slug(&s.name, &app_id).map(str::to_string))
.flatten();
let mut peer = Peer::from(s);
if let Some(bare) = bare {
peer.device_name = bare;
}
peer
})
.collect()
}
pub fn on_peer_change(&self) -> broadcast::Receiver<PeerEvent> {
self.session.on_peer_change()
}
pub(crate) async fn resolve_peer(&self, peer_ref: &str) -> Result<PeerState, NodeError> {
let peers = self.session.peers().await;
for p in &peers {
if let Some(identity) = p.identity.as_ref() {
if identity.device_id == peer_ref || identity.device_name == peer_ref {
return Ok(p.clone());
}
}
if p.id == peer_ref || p.name == peer_ref || p.ip.to_string() == peer_ref {
return Ok(p.clone());
}
}
let app_id = self.network.local_identity().app_id;
let ref_slug = identity::slug(peer_ref, 255);
if !ref_slug.is_empty() {
for p in &peers {
if hostname_slug(&p.name, &app_id) == Some(ref_slug.as_str()) {
return Ok(p.clone());
}
}
}
if peer_ref.len() >= 4 {
let mut hits = peers.iter().filter(|p| {
p.identity
.as_ref()
.map(|i| i.device_id.starts_with(peer_ref))
.unwrap_or(false)
});
if let (Some(hit), None) = (hits.next(), hits.next()) {
return Ok(hit.clone());
}
}
Err(NodeError::PeerNotFound(peer_ref.to_string()))
}
pub async fn resolve_peer_ip(&self, peer_ref: &str) -> Result<IpAddr, NodeError> {
Ok(self.resolve_peer(peer_ref).await?.ip)
}
pub async fn resolve_peer_id(&self, peer_id: &str) -> Result<String, NodeError> {
let p = self.resolve_peer(peer_id).await?;
Ok(p.identity
.as_ref()
.map(|i| i.device_id.clone())
.unwrap_or_else(|| p.id.clone()))
}
pub async fn ping(&self, peer_id: &str) -> Result<PingResult, NodeError> {
let peer = self.resolve_peer(peer_id).await?;
let addr = peer.ip.to_string();
self.network.ping(&addr).await.map_err(NodeError::Network)
}
pub async fn health(&self) -> HealthInfo {
self.network.health().await
}
fn ensure_not_stopped(&self) -> Result<(), NodeError> {
if self.stopped.load(std::sync::atomic::Ordering::SeqCst) {
return Err(NodeError::Stopped);
}
Ok(())
}
pub async fn send(&self, peer_id: &str, namespace: &str, data: &[u8]) -> Result<(), NodeError> {
self.ensure_not_stopped()?;
let payload = std::str::from_utf8(data)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.unwrap_or_else(|| serde_json::Value::from(data.to_vec()));
let envelope = Envelope::new(namespace, "message", payload).with_timestamp();
let encoded = self.codec.encode(&envelope)?;
self.session.send(peer_id, &encoded).await?;
Ok(())
}
pub async fn send_typed(
&self,
peer_id: &str,
namespace: &str,
msg_type: &str,
payload: &serde_json::Value,
) -> Result<(), NodeError> {
self.ensure_not_stopped()?;
let envelope = Envelope::new(namespace, msg_type, payload.clone()).with_timestamp();
let encoded = self.codec.encode(&envelope)?;
self.session.send(peer_id, &encoded).await?;
Ok(())
}
pub async fn broadcast_typed(
&self,
namespace: &str,
msg_type: &str,
payload: &serde_json::Value,
) {
if self.stopped.load(std::sync::atomic::Ordering::SeqCst) {
tracing::debug!("node: broadcast after stop ignored");
return;
}
let envelope = Envelope::new(namespace, msg_type, payload.clone()).with_timestamp();
match self.codec.encode(&envelope) {
Ok(encoded) => {
self.session.broadcast(&encoded).await;
}
Err(e) => {
tracing::error!("node: failed to encode broadcast envelope: {e}");
}
}
}
pub async fn broadcast(&self, namespace: &str, data: &[u8]) {
if self.stopped.load(std::sync::atomic::Ordering::SeqCst) {
tracing::debug!("node: broadcast after stop ignored");
return;
}
let payload = std::str::from_utf8(data)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.unwrap_or_else(|| serde_json::Value::from(data.to_vec()));
let envelope = Envelope::new(namespace, "message", payload).with_timestamp();
match self.codec.encode(&envelope) {
Ok(encoded) => {
self.session.broadcast(&encoded).await;
}
Err(e) => {
tracing::error!("node: failed to encode broadcast envelope: {e}");
}
}
}
pub fn subscribe(&self, namespace: &str) -> broadcast::Receiver<NamespacedMessage> {
{
let filters = self
.namespace_filters
.read()
.expect("namespace_filters std RwLock poisoned");
if let Some(tx) = filters.get(namespace) {
return tx.subscribe();
}
}
let mut filters = self
.namespace_filters
.write()
.expect("namespace_filters std RwLock poisoned");
if let Some(tx) = filters.get(namespace) {
return tx.subscribe();
}
let (tx, rx) = broadcast::channel(256);
filters.insert(namespace.to_string(), tx);
rx
}
pub async fn open_tcp(&self, peer_id: &str, port: u16) -> Result<TcpStream, NodeError> {
let peer = self.resolve_peer(peer_id).await?;
let addr = peer.ip.to_string();
self.network
.dial_tcp_opts(&addr, port, DialOpts { tls: Some(false) })
.await
.map_err(|e| NodeError::ConnectionFailed(e.to_string()))
}
pub async fn listen_tcp(&self, port: u16) -> Result<RawListener, NodeError> {
use crate::transport::tcp::TcpTransport;
use crate::transport::RawTransport;
ensure_port_unreserved(port, self.ws_port)?;
let tcp = TcpTransport::new(self.network.clone());
tcp.listen(port).await.map_err(NodeError::Transport)
}
pub async fn unlisten_tcp(&self, port: u16) -> Result<(), NodeError> {
self.network
.unlisten_tcp(port)
.await
.map_err(NodeError::Network)
}
pub async fn connect_quic(
&self,
peer_id: &str,
port: u16,
) -> Result<QuicConnection, NodeError> {
let peer = self.resolve_peer(peer_id).await?;
let alpn = crate::transport::quic::raw_alpn(&self.network.local_identity().app_id);
crate::transport::quic::connect_raw(&self.network, &peer.ip.to_string(), port, &alpn)
.await
.map_err(NodeError::Transport)
}
pub async fn listen_quic(&self, port: u16) -> Result<QuicListener, NodeError> {
ensure_port_unreserved(port, self.ws_port)?;
if port == 0 {
return Err(NodeError::NotImplemented(
"ephemeral (port 0) QUIC listeners are not supported over the tsnet relay yet — choose an explicit port"
.to_string(),
));
}
let alpn = crate::transport::quic::raw_alpn(&self.network.local_identity().app_id);
crate::transport::quic::listen_raw(&self.network, port, &alpn)
.await
.map_err(NodeError::Transport)
}
pub async fn bind_udp(&self, port: u16) -> Result<DatagramSocket, NodeError> {
use crate::transport::udp::{UdpConfig, UdpTransport};
use crate::transport::DatagramTransport;
let udp = UdpTransport::new(self.network.clone(), UdpConfig::default());
udp.bind(port).await.map_err(NodeError::Transport)
}
}
fn hostname_slug<'a>(hostname: &'a str, app_id: &str) -> Option<&'a str> {
hostname
.strip_prefix("truffle-")?
.strip_prefix(app_id)?
.strip_prefix('-')
}
fn ensure_port_unreserved(port: u16, ws_port: u16) -> Result<(), NodeError> {
if port == 443 || port == ws_port {
Err(NodeError::ReservedPort(port))
} else {
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct NodeBuilder {
app_id: Option<AppId>,
device_name: Option<DeviceName>,
device_id: Option<DeviceId>,
sidecar_path: Option<PathBuf>,
state_dir: Option<String>,
auth_key: Option<String>,
ephemeral: bool,
ws_port: u16,
idle_timeout_secs: Option<u64>,
}
fn atomic_write_string(path: &Path, content: &str) -> std::io::Result<()> {
let _parent = path.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"path has no parent directory",
)
})?;
let mut tmp = path.to_path_buf();
tmp.set_extension("tmp");
std::fs::write(&tmp, content)?;
#[cfg(unix)]
{
std::fs::rename(&tmp, path)?;
}
#[cfg(windows)]
{
let _ = std::fs::remove_file(path);
std::fs::rename(&tmp, path)?;
}
Ok(())
}
impl Default for NodeBuilder {
fn default() -> Self {
Self {
app_id: None,
device_name: None,
device_id: None,
sidecar_path: None,
state_dir: None,
auth_key: None,
ephemeral: false,
ws_port: 9417,
idle_timeout_secs: None,
}
}
}
impl NodeBuilder {
pub fn app_id(mut self, s: impl Into<String>) -> Result<Self, NodeError> {
let raw: String = s.into();
let app_id = AppId::parse(&raw)
.map_err(|e| NodeError::BuildError(format!("invalid app_id: {e}")))?;
self.app_id = Some(app_id);
Ok(self)
}
pub fn device_name(mut self, s: impl Into<String>) -> Self {
self.device_name = Some(DeviceName::parse(s));
self
}
pub fn device_id(mut self, s: impl Into<String>) -> Result<Self, NodeError> {
let raw: String = s.into();
let device_id = DeviceId::parse(&raw)
.map_err(|e| NodeError::BuildError(format!("invalid device_id: {e}")))?;
self.device_id = Some(device_id);
Ok(self)
}
pub fn sidecar_path(mut self, path: impl Into<PathBuf>) -> Self {
self.sidecar_path = Some(path.into());
self
}
pub fn state_dir(mut self, dir: &str) -> Self {
self.state_dir = Some(dir.to_string());
self
}
pub fn auth_key(mut self, key: &str) -> Self {
self.auth_key = Some(key.to_string());
self
}
pub fn ephemeral(mut self, val: bool) -> Self {
self.ephemeral = val;
self
}
pub fn ws_port(mut self, port: u16) -> Self {
self.ws_port = port;
self
}
pub fn idle_timeout_secs(mut self, secs: u64) -> Self {
self.idle_timeout_secs = Some(secs);
self
}
fn prepare_config(&self) -> Result<TailscaleConfig, NodeError> {
let binary_path = self
.sidecar_path
.clone()
.ok_or_else(|| NodeError::BuildError("sidecar_path is required".into()))?;
let app_id = self
.app_id
.clone()
.ok_or_else(|| NodeError::BuildError("app_id is required".into()))?;
let device_name = match self.device_name.clone() {
Some(name) => name,
None => {
let os_hostname = hostname::get()
.map_err(|e| {
NodeError::BuildError(format!(
"device_name is unset and hostname::get() failed: {e}"
))
})?
.to_string_lossy()
.into_owned();
DeviceName::parse(os_hostname)
}
};
let tailscale_host = identity::tailscale_hostname(&app_id, &device_name);
let state_dir = self.state_dir.clone().unwrap_or_else(|| {
let base = dirs::data_dir().unwrap_or_else(|| {
tracing::warn!(
"dirs::data_dir() returned None, falling back to std::env::temp_dir()"
);
std::env::temp_dir()
});
base.join("truffle")
.join(app_id.as_str())
.join(identity::slug(device_name.as_str(), 255))
.to_string_lossy()
.into_owned()
});
std::fs::create_dir_all(&state_dir)?;
let device_id_file = Path::new(&state_dir).join("device-id.txt");
let device_id = match self.device_id.clone() {
Some(id) => {
atomic_write_string(&device_id_file, id.as_str())?;
id
}
None => {
if device_id_file.exists() {
let s = std::fs::read_to_string(&device_id_file)?.trim().to_string();
DeviceId::parse(&s).map_err(|e| {
NodeError::BuildError(format!(
"device-id.txt at {device_id_file:?} contains an invalid ULID: {e}"
))
})?
} else {
let id = DeviceId::generate();
atomic_write_string(&device_id_file, id.as_str())?;
id
}
}
};
Ok(TailscaleConfig {
binary_path,
app_id: app_id.as_str().to_string(),
device_id: device_id.as_str().to_string(),
device_name: device_name.as_str().to_string(),
hostname: tailscale_host,
state_dir,
auth_key: self.auth_key.clone(),
ephemeral: if self.ephemeral { Some(true) } else { None },
tags: None,
idle_timeout_secs: self.idle_timeout_secs,
})
}
pub async fn build(self) -> Result<Node<TailscaleProvider>, NodeError> {
let ws_port = self.ws_port;
let config = self.prepare_config()?;
let state_dir = PathBuf::from(&config.state_dir);
let mut provider = TailscaleProvider::new(config);
provider.start().await.map_err(NodeError::Network)?;
let network = Arc::new(provider);
let ws_config = WsConfig {
port: ws_port,
..Default::default()
};
let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config));
let session = Arc::new(PeerRegistry::new(network.clone(), ws_transport));
session.start().await;
let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
let node = Node::from_parts(network, session, codec)
.with_state_dir(state_dir)
.with_ws_port(ws_port);
tracing::info!("node: started successfully");
Ok(node)
}
pub async fn build_with_auth_handler(
self,
on_auth: impl Fn(String) + Send + 'static,
) -> Result<Node<TailscaleProvider>, NodeError> {
let ws_port = self.ws_port;
let config = self.prepare_config()?;
let state_dir = PathBuf::from(&config.state_dir);
let mut provider = TailscaleProvider::new(config);
let mut auth_rx = provider.peer_events();
let auth_task = tokio::spawn(async move {
use crate::network::NetworkPeerEvent;
loop {
match auth_rx.recv().await {
Ok(NetworkPeerEvent::AuthRequired { url }) => {
on_auth(url);
}
Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
_ => {} }
}
});
let start_result = provider.start().await.map_err(NodeError::Network);
auth_task.abort();
start_result?;
let network = Arc::new(provider);
let ws_config = WsConfig {
port: ws_port,
..Default::default()
};
let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config));
let session = Arc::new(PeerRegistry::new(network.clone(), ws_transport));
session.start().await;
let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
let node = Node::from_parts(network, session, codec)
.with_state_dir(state_dir)
.with_ws_port(ws_port);
tracing::info!("node: started successfully (with auth handler)");
Ok(node)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::{
HealthInfo, IncomingConnection, NetworkError, NetworkPeer, NetworkPeerEvent,
NetworkTcpListener, NetworkUdpSocket, PeerAddr,
};
use crate::transport::WsConfig;
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::{broadcast, mpsc};
struct MockNetworkProvider {
identity: NodeIdentity,
local_addr: PeerAddr,
peer_event_tx: broadcast::Sender<NetworkPeerEvent>,
mock_peers: Arc<RwLock<Vec<NetworkPeer>>>,
stop_calls: Arc<AtomicUsize>,
}
impl MockNetworkProvider {
fn new(id: &str) -> Self {
let (peer_event_tx, _) = broadcast::channel(64);
Self {
identity: NodeIdentity {
app_id: "test".to_string(),
device_id: id.to_string(),
device_name: format!("Test Node {id}"),
tailscale_hostname: format!("truffle-test-{id}"),
tailscale_id: id.to_string(),
dns_name: None,
ip: Some("127.0.0.1".parse().unwrap()),
},
local_addr: PeerAddr {
ip: Some("127.0.0.1".parse().unwrap()),
hostname: format!("truffle-test-{id}"),
dns_name: None,
},
peer_event_tx,
mock_peers: Arc::new(RwLock::new(Vec::new())),
stop_calls: Arc::new(AtomicUsize::new(0)),
}
}
fn event_sender(&self) -> broadcast::Sender<NetworkPeerEvent> {
self.peer_event_tx.clone()
}
fn stop_call_count(&self) -> usize {
self.stop_calls.load(Ordering::SeqCst)
}
}
impl NetworkProvider for MockNetworkProvider {
async fn start(&mut self) -> Result<(), NetworkError> {
Ok(())
}
async fn stop(&self) -> Result<(), NetworkError> {
self.stop_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn local_identity(&self) -> NodeIdentity {
self.identity.clone()
}
fn local_addr(&self) -> PeerAddr {
self.local_addr.clone()
}
fn peer_events(&self) -> broadcast::Receiver<NetworkPeerEvent> {
self.peer_event_tx.subscribe()
}
async fn peers(&self) -> Vec<NetworkPeer> {
self.mock_peers.read().await.clone()
}
async fn dial_tcp(&self, addr: &str, port: u16) -> Result<TcpStream, NetworkError> {
let target = format!("{addr}:{port}");
TcpStream::connect(&target)
.await
.map_err(|e| NetworkError::DialFailed(format!("mock dial {target}: {e}")))
}
async fn listen_tcp(&self, port: u16) -> Result<NetworkTcpListener, NetworkError> {
let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
.await
.map_err(|e| NetworkError::ListenFailed(format!("mock listen :{port}: {e}")))?;
let actual_port = listener.local_addr().unwrap().port();
let (tx, rx) = mpsc::channel::<IncomingConnection>(64);
tokio::spawn(async move {
loop {
match listener.accept().await {
Ok((stream, addr)) => {
let conn = IncomingConnection {
stream,
remote_addr: addr.to_string(),
remote_identity: String::new(),
port: actual_port,
};
if tx.send(conn).await.is_err() {
break;
}
}
Err(e) => {
tracing::debug!("mock listener error: {e}");
break;
}
}
}
});
Ok(NetworkTcpListener {
port: actual_port,
incoming: rx,
})
}
async fn unlisten_tcp(&self, _port: u16) -> Result<(), NetworkError> {
Ok(())
}
async fn bind_udp(&self, _port: u16) -> Result<NetworkUdpSocket, NetworkError> {
Err(NetworkError::Internal("mock: UDP not supported".into()))
}
async fn ping(&self, _addr: &str) -> Result<PingResult, NetworkError> {
Ok(PingResult {
latency: Duration::from_millis(1),
connection: "direct".to_string(),
peer_addr: None,
})
}
async fn health(&self) -> HealthInfo {
HealthInfo {
state: "running".to_string(),
healthy: true,
..Default::default()
}
}
}
fn make_loopback_peer(id: &str) -> NetworkPeer {
NetworkPeer {
id: id.to_string(),
hostname: format!("truffle-test-{id}"),
ip: "127.0.0.1".parse().unwrap(),
online: true,
cur_addr: Some("127.0.0.1:41641".to_string()),
relay: None,
os: Some("linux".to_string()),
last_seen: Some("2026-03-25T12:00:00Z".to_string()),
key_expiry: None,
dns_name: None,
}
}
fn ws_config(port: u16) -> WsConfig {
WsConfig {
port,
ping_interval: Duration::from_secs(300),
pong_timeout: Duration::from_secs(300),
..Default::default()
}
}
async fn random_port() -> u16 {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
l.local_addr().unwrap().port()
}
async fn make_test_node(
id: &str,
ws_port: u16,
) -> (
Node<MockNetworkProvider>,
broadcast::Sender<NetworkPeerEvent>,
Arc<MockNetworkProvider>,
) {
let provider = MockNetworkProvider::new(id);
let event_tx = provider.event_sender();
let network = Arc::new(provider);
let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config(ws_port)));
let session = Arc::new(PeerRegistry::new(network.clone(), ws_transport));
session.start().await;
let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
let node = Node::from_parts(network.clone(), session, codec);
(node, event_tx, network)
}
#[tokio::test]
async fn test_node_builder_creates_node() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
let identity = node.local_info();
assert_eq!(identity.tailscale_id, "node-1");
assert_eq!(identity.device_id, "node-1");
assert!(identity.tailscale_hostname.contains("node-1"));
}
#[tokio::test]
async fn test_node_peers_from_network() {
let ws_port = random_port().await;
let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
let peers = node.peers().await;
assert!(peers.is_empty());
let peer = make_loopback_peer("peer-a");
let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
tokio::time::sleep(Duration::from_millis(50)).await;
let peers = node.peers().await;
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].id, "peer-a");
assert!(peers[0].online);
assert!(!peers[0].ws_connected);
}
#[tokio::test]
async fn test_node_send_to_unknown_peer_errors() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
let result = node.send("nonexistent", "test", b"hello").await;
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("unknown peer") || err_str.contains("not found"),
"expected unknown peer error, got: {err_str}"
);
}
#[tokio::test]
async fn test_node_send_wraps_in_envelope() {
let codec = JsonCodec;
let data = b"hello world";
let envelope = Envelope::new("test-ns", "message", serde_json::Value::from(data.to_vec()))
.with_timestamp();
let encoded = codec.encode(&envelope).unwrap();
let decoded = codec.decode(&encoded).unwrap();
assert_eq!(decoded.namespace, "test-ns");
assert_eq!(decoded.msg_type, "message");
assert!(decoded.timestamp.is_some());
}
#[tokio::test]
async fn test_node_subscribe_filters_by_namespace() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
let _rx_chat = node.subscribe("chat");
let _rx_ft = node.subscribe("ft");
let _rx_chat2 = node.subscribe("chat");
}
#[tokio::test]
async fn test_node_broadcast() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
node.broadcast("test", b"hello everyone").await;
}
#[tokio::test]
async fn test_node_open_tcp_resolves_peer() {
let ws_port = random_port().await;
let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let tcp_port = listener.local_addr().unwrap().port();
let peer = make_loopback_peer("peer-tcp");
let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
tokio::time::sleep(Duration::from_millis(50)).await;
let accept_handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
stream
});
let stream = node.open_tcp("peer-tcp", tcp_port).await;
assert!(stream.is_ok(), "open_tcp failed: {:?}", stream.err());
let _ = accept_handle.await;
}
#[tokio::test]
async fn test_node_open_tcp_unknown_peer_errors() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
let result = node.open_tcp("nonexistent", 8080).await;
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("not found"),
"expected peer not found error, got: {err_str}"
);
}
#[tokio::test]
async fn test_node_ping_resolves_peer() {
let ws_port = random_port().await;
let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
let result = node.ping("peer-ping").await;
assert!(result.is_err());
let peer = make_loopback_peer("peer-ping");
let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
tokio::time::sleep(Duration::from_millis(50)).await;
let result = node.ping("peer-ping").await;
assert!(result.is_ok());
assert_eq!(result.unwrap().latency, Duration::from_millis(1));
}
#[tokio::test]
async fn test_node_health() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
let health = node.health().await;
assert!(health.healthy);
assert_eq!(health.state, "running");
}
#[tokio::test]
async fn test_node_connect_quic_unknown_peer_errors() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
let result = node.connect_quic("peer", 4433).await;
assert!(matches!(result, Err(NodeError::PeerNotFound(_))));
}
#[tokio::test]
async fn test_node_listen_tcp() {
let ws_port = random_port().await;
let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
let listener = node.listen_tcp(0).await;
assert!(listener.is_ok(), "listen_tcp failed: {:?}", listener.err());
}
#[tokio::test]
async fn test_envelope_serialize_deserialize() {
let envelope = Envelope::new("chat", "message", json!({"text": "hello"})).with_timestamp();
let bytes = envelope.serialize().unwrap();
let decoded = Envelope::deserialize(&bytes).unwrap();
assert_eq!(decoded.namespace, "chat");
assert_eq!(decoded.msg_type, "message");
assert_eq!(decoded.payload["text"], "hello");
assert!(decoded.timestamp.is_some());
}
#[tokio::test]
async fn test_envelope_codec_json() {
let codec = JsonCodec;
let envelope = Envelope::new("ft", "offer", json!({"file": "test.bin"}));
let encoded = codec.encode(&envelope).unwrap();
let decoded = codec.decode(&encoded).unwrap();
assert_eq!(decoded.namespace, "ft");
assert_eq!(decoded.payload["file"], "test.bin");
}
#[tokio::test]
async fn test_envelope_unknown_fields_ignored() {
let json_bytes = br#"{
"namespace": "v2",
"msg_type": "new",
"payload": {},
"future_field": "ignored"
}"#;
let codec = JsonCodec;
let decoded = codec.decode(json_bytes).unwrap();
assert_eq!(decoded.namespace, "v2");
assert_eq!(decoded.msg_type, "new");
}
#[tokio::test]
async fn test_node_send_and_receive_roundtrip() {
let port_a = random_port().await;
let port_b = random_port().await;
let (node_a, event_tx_a, _net_a) = make_test_node("node-a", port_a).await;
let (node_b, event_tx_b, _net_b) = make_test_node("node-b", port_b).await;
let peer_b = NetworkPeer {
id: "node-b".to_string(),
hostname: "truffle-test-node-b".to_string(),
ip: "127.0.0.1".parse().unwrap(),
online: true,
cur_addr: Some("127.0.0.1:41641".to_string()),
relay: None,
os: None,
last_seen: None,
key_expiry: None,
dns_name: None,
};
let peer_a = NetworkPeer {
id: "node-a".to_string(),
hostname: "truffle-test-node-a".to_string(),
ip: "127.0.0.1".parse().unwrap(),
online: true,
cur_addr: Some("127.0.0.1:41641".to_string()),
relay: None,
os: None,
last_seen: None,
key_expiry: None,
dns_name: None,
};
let _ = event_tx_a.send(NetworkPeerEvent::Joined(peer_b));
let _ = event_tx_b.send(NetworkPeerEvent::Joined(peer_a));
tokio::time::sleep(Duration::from_millis(100)).await;
let mut rx = node_b.subscribe("test");
let send_result = node_a.send("node-b", "test", b"hello from a").await;
if send_result.is_ok() {
let msg = tokio::time::timeout(Duration::from_secs(2), rx.recv()).await;
if let Ok(Ok(msg)) = msg {
assert_eq!(msg.namespace, "test");
}
}
}
#[tokio::test]
async fn test_node_stop_shuts_down_provider_and_is_idempotent() {
let ws_port = random_port().await;
let (node, event_tx, network) = make_test_node("node-1", ws_port).await;
let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-1")));
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(network.stop_call_count(), 0);
node.stop().await;
assert_eq!(
network.stop_call_count(),
1,
"stop() must shut down the provider"
);
node.stop().await;
assert_eq!(network.stop_call_count(), 1);
let err = node.send("peer-1", "test", b"hi").await.unwrap_err();
assert!(matches!(err, NodeError::Stopped));
}
#[tokio::test]
async fn test_node_stop_closes_ws_connections() {
let port_a = random_port().await;
let port_b = random_port().await;
let (node_a, event_tx_a, _net_a) = make_test_node("node-a", port_a).await;
let (node_b, event_tx_b, _net_b) = make_test_node("node-b", port_b).await;
let peer_b = NetworkPeer {
id: "node-b".to_string(),
hostname: "truffle-test-node-b".to_string(),
ip: "127.0.0.1".parse().unwrap(),
online: true,
cur_addr: Some("127.0.0.1:41641".to_string()),
relay: None,
os: None,
last_seen: None,
key_expiry: None,
dns_name: None,
};
let peer_a = NetworkPeer {
id: "node-a".to_string(),
hostname: "truffle-test-node-a".to_string(),
ip: "127.0.0.1".parse().unwrap(),
online: true,
cur_addr: Some("127.0.0.1:41641".to_string()),
relay: None,
os: None,
last_seen: None,
key_expiry: None,
dns_name: None,
};
let _ = event_tx_a.send(NetworkPeerEvent::Joined(peer_b));
let _ = event_tx_b.send(NetworkPeerEvent::Joined(peer_a));
tokio::time::sleep(Duration::from_millis(100)).await;
let _rx = node_b.subscribe("test");
node_a
.send("node-b", "test", b"hello from a")
.await
.expect("loopback send should establish a WS connection");
let peers = node_a.peers().await;
let b = peers
.iter()
.find(|p| p.tailscale_id == "node-b")
.expect("peer b discovered");
assert!(
b.ws_connected,
"send() should have established a WS connection to node-b"
);
node_a.stop().await;
let peers = node_a.peers().await;
let b = peers
.iter()
.find(|p| p.tailscale_id == "node-b")
.expect("peer b still discovered");
assert!(
!b.ws_connected,
"stop() must close and un-mark WS connections"
);
}
async fn inject_peer_with_identity(
node: &Node<MockNetworkProvider>,
event_tx: &broadcast::Sender<NetworkPeerEvent>,
tailscale_id: &str,
device_id: &str,
device_name: &str,
) {
let peer = make_loopback_peer(tailscale_id);
let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
tokio::time::sleep(Duration::from_millis(30)).await;
let identity = crate::session::PeerIdentity {
app_id: "test".into(),
device_id: device_id.into(),
device_name: device_name.into(),
os: "linux".into(),
tailscale_id: tailscale_id.into(),
};
assert!(
node.session
.test_stamp_identity(tailscale_id, identity)
.await,
"peer {tailscale_id} should exist in session registry before stamping identity"
);
}
#[tokio::test]
async fn test_resolve_peer_id_by_device_id() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
inject_peer_with_identity(
&node,
&event_tx,
"tailscale-abc",
"01HZZZZZZZZZZZZZZZZZZZZZZZ",
"Alice MacBook",
)
.await;
let resolved = node
.resolve_peer_id("01HZZZZZZZZZZZZZZZZZZZZZZZ")
.await
.unwrap();
assert_eq!(resolved, "01HZZZZZZZZZZZZZZZZZZZZZZZ");
}
#[tokio::test]
async fn test_resolve_peer_id_by_device_name() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
inject_peer_with_identity(
&node,
&event_tx,
"tailscale-abc",
"01HXYZXYZXYZXYZXYZXYZXYZXY",
"Bob's Mac",
)
.await;
let resolved = node.resolve_peer_id("Bob's Mac").await.unwrap();
assert_eq!(resolved, "01HXYZXYZXYZXYZXYZXYZXYZXY");
}
#[tokio::test]
async fn test_resolve_peer_id_by_device_id_prefix() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
inject_peer_with_identity(
&node,
&event_tx,
"tailscale-abc",
"01HXYZXYZXYZXYZXYZXYZXYZXY",
"laptop",
)
.await;
let resolved = node.resolve_peer_id("01HX").await.unwrap();
assert_eq!(resolved, "01HXYZXYZXYZXYZXYZXYZXYZXY");
}
#[tokio::test]
async fn test_resolve_peer_id_by_tailscale_id_legacy() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
inject_peer_with_identity(
&node,
&event_tx,
"tailscale-legacy",
"01HLEGACY0000000000000000X",
"legacy box",
)
.await;
let resolved = node.resolve_peer_id("tailscale-legacy").await.unwrap();
assert_eq!(resolved, "01HLEGACY0000000000000000X");
}
#[tokio::test]
async fn test_resolve_peer_id_unknown() {
let ws_port = random_port().await;
let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
let result = node.resolve_peer_id("nope").await;
assert!(matches!(result, Err(NodeError::PeerNotFound(_))));
}
#[tokio::test]
async fn test_resolve_peer_by_bare_name_before_hello() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
let mut peer = make_loopback_peer("nodeid-9");
peer.hostname = "truffle-test-ec2-smoke".to_string();
let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(node.resolve_peer("ec2-smoke").await.unwrap().id, "nodeid-9");
assert_eq!(node.resolve_peer("EC2 Smoke").await.unwrap().id, "nodeid-9");
assert!(node.resolve_peer("other-box").await.is_err());
}
#[tokio::test]
async fn test_peers_device_name_strips_hostname_before_hello() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
let mut peer = make_loopback_peer("nodeid-9");
peer.hostname = "truffle-test-ec2-smoke".to_string();
let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
tokio::time::sleep(Duration::from_millis(50)).await;
let peers = node.peers().await;
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].device_name, "ec2-smoke");
assert_eq!(peers[0].name, "truffle-test-ec2-smoke");
}
#[tokio::test]
async fn test_resolve_peer_accepts_ip() {
let ws_port = random_port().await;
let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-a")));
tokio::time::sleep(Duration::from_millis(50)).await;
let resolved = node.resolve_peer("127.0.0.1").await.unwrap();
assert_eq!(resolved.id, "peer-a");
assert_eq!(node.resolve_peer_id("127.0.0.1").await.unwrap(), "peer-a");
}
#[tokio::test]
async fn test_listen_tcp_rejects_reserved_ports() {
let ws_port = random_port().await;
let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
for port in [443u16, 9417] {
let err = node.listen_tcp(port).await.unwrap_err();
assert!(
matches!(err, NodeError::ReservedPort(p) if p == port),
"expected ReservedPort({port}), got: {err}"
);
}
}
#[tokio::test]
async fn test_listen_quic_rejects_reserved_and_ephemeral_ports() {
let ws_port = random_port().await;
let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
let err = node.listen_quic(443).await.unwrap_err();
assert!(matches!(err, NodeError::ReservedPort(443)));
let err = node.listen_quic(0).await.unwrap_err();
assert!(matches!(err, NodeError::NotImplemented(_)));
}
#[tokio::test]
async fn test_bind_udp_falls_back_to_direct_socket() {
let ws_port = random_port().await;
let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
let socket = node.bind_udp(0).await.unwrap();
let addr = socket.local_addr().unwrap();
assert_ne!(addr.port(), 0);
}
#[test]
fn test_raw_alpn_scoping() {
assert_eq!(
crate::transport::quic::raw_alpn("demo"),
b"truffle-raw.demo".to_vec()
);
assert_eq!(
crate::transport::quic::raw_alpn(""),
b"truffle-raw".to_vec()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_connect_quic_roundtrip_via_raw_listener() {
let ws_port = random_port().await;
let (client_node, event_tx, _net) = make_test_node("cli", ws_port).await;
let server_network = Arc::new(MockNetworkProvider::new("srv"));
let alpn = crate::transport::quic::raw_alpn("test");
let listener = crate::transport::quic::listen_raw(&server_network, 0, &alpn)
.await
.unwrap();
let port = listener.port();
assert_ne!(port, 0);
let echo_task = tokio::spawn(async move {
let conn = listener.accept().await.expect("no incoming connection");
let mut stream = conn
.accept_stream()
.await
.unwrap()
.expect("connection closed before stream");
let mut received = Vec::new();
while let Some(chunk) = stream.read(1024).await.unwrap() {
received.extend_from_slice(&chunk);
}
stream.write(&received).await.unwrap();
stream.finish();
tokio::time::sleep(Duration::from_millis(500)).await;
conn.close();
});
let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("srv-peer")));
tokio::time::sleep(Duration::from_millis(50)).await;
let conn = client_node.connect_quic("srv-peer", port).await.unwrap();
let mut stream = conn.open_stream().await.unwrap();
stream.write(b"hello quic").await.unwrap();
stream.finish();
let mut echoed = Vec::new();
while let Some(chunk) = stream.read(1024).await.unwrap() {
echoed.extend_from_slice(&chunk);
}
assert_eq!(echoed, b"hello quic");
echo_task.await.unwrap();
conn.close();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quic_raw_alpn_mismatch_rejected() {
let server_network = Arc::new(MockNetworkProvider::new("srv"));
let listener = crate::transport::quic::listen_raw(
&server_network,
0,
&crate::transport::quic::raw_alpn("app-a"),
)
.await
.unwrap();
let port = listener.port();
let client_network = Arc::new(MockNetworkProvider::new("cli"));
let result = crate::transport::quic::connect_raw(
&client_network,
"127.0.0.1",
port,
&crate::transport::quic::raw_alpn("app-b"),
)
.await;
assert!(
result.is_err(),
"cross-app QUIC connect must fail (ALPN mismatch)"
);
}
}