use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use zyris::{AcceptOptions, Node, NodeKind, Result};
use zyris_attacca::{AttaccaApi, AttaccaApiClient, ZPeerEntry};
use zyris_caps::peer_transfer::PeerTransferServer;
use zyris_p2p::iroh;
use zyris_p2p::tofu::TofuStore;
use super::peer::{LocalPeerTransfer, TransferConfig};
use super::rendezvous::Rendezvous;
pub const DEFAULT_DIRECTORY_TTL: Duration = Duration::from_secs(60);
pub const DEFAULT_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
const HANDSHAKE_DEADLINE: Duration = Duration::from_secs(10);
const CAPABILITY_WAIT: Duration = Duration::from_secs(5);
#[async_trait::async_trait]
pub trait PeerDirectory: Send + Sync {
async fn peers(&self) -> Result<Vec<ZPeerEntry>>;
}
#[async_trait::async_trait]
impl PeerDirectory for AttaccaApiClient {
async fn peers(&self) -> Result<Vec<ZPeerEntry>> {
AttaccaApi::peer_list(self).await
}
}
#[async_trait::async_trait]
impl PeerDirectory for Rendezvous {
async fn peers(&self) -> Result<Vec<ZPeerEntry>> {
match self.get() {
Some(api) => AttaccaApi::peer_list(api.as_ref()).await,
None => Err(zyris::WireError::new(
zyris::ErrorCode::ConnectionLost,
"not connected to Attacca, so the peer list cannot be refreshed",
)
.into()),
}
}
}
pub struct PeerCache {
directory: Arc<dyn PeerDirectory>,
ttl: Duration,
refresh_interval: Duration,
by_endpoint: HashMap<String, ZPeerEntry>,
fetched: Option<Instant>,
attempted: Option<Instant>,
}
impl PeerCache {
pub fn new(
directory: Arc<dyn PeerDirectory>,
ttl: Duration,
refresh_interval: Duration,
) -> PeerCache {
PeerCache {
directory,
ttl,
refresh_interval,
by_endpoint: HashMap::new(),
fetched: None,
attempted: None,
}
}
pub async fn find(&mut self, endpoint_id: &str) -> Option<ZPeerEntry> {
let fresh = self.fetched.is_some_and(|at| at.elapsed() < self.ttl);
if fresh {
if let Some(entry) = self.by_endpoint.get(endpoint_id) {
return Some(entry.clone());
}
}
if self.may_attempt() {
self.refresh().await;
}
self.by_endpoint.get(endpoint_id).cloned()
}
fn may_attempt(&self) -> bool {
self.attempted.is_none_or(|at| at.elapsed() >= self.refresh_interval)
}
async fn refresh(&mut self) {
self.attempted = Some(Instant::now());
match self.directory.peers().await {
Ok(entries) => {
self.by_endpoint = entries
.into_iter()
.map(|entry| (entry.endpoint_id.clone(), entry))
.collect();
self.fetched = Some(Instant::now());
}
Err(error) => {
tracing::warn!(%error, "could not refresh the node list; keeping the one we have");
}
}
}
}
pub async fn serve_peers(
endpoint: iroh::Endpoint,
directory: Arc<dyn PeerDirectory>,
config: TransferConfig,
tofu: TofuStore,
node_id: String,
) {
let directory = Arc::new(Mutex::new(PeerCache::new(
directory,
DEFAULT_DIRECTORY_TTL,
DEFAULT_REFRESH_INTERVAL,
)));
while let Some(accepting) = zyris_p2p::peer::accept_next(&endpoint).await {
let config = config.clone();
let directory = directory.clone();
let tofu = tofu.clone();
let node_id = node_id.clone();
tokio::spawn(async move {
serve_one(accepting, config, directory, tofu, node_id).await;
});
}
}
async fn serve_one(
accepting: zyris_p2p::peer::PendingConnection,
config: TransferConfig,
directory: Arc<Mutex<PeerCache>>,
tofu: TofuStore,
node_id: String,
) {
let (peer, transport) =
match zyris_p2p::peer::establish(accepting, HANDSHAKE_DEADLINE).await {
Ok(established) => established,
Err(error) => {
tracing::warn!(%error, "the handshake did not finish; closing");
return;
}
};
let peer = peer.to_string();
let Some(entry) = directory.lock().await.find(&peer).await else {
tracing::warn!(%peer, "not a node of this account; closing");
return;
};
let slug = entry.slug.clone();
if let Err(error) = tofu.check(&slug, &peer).await {
tracing::error!(%peer, %slug, %error, "the peer's key is not the one pinned for this name; refusing");
return;
}
let receiving = LocalPeerTransfer::receiver_pending(config, slug.clone());
let node = match Node::builder()
.name("peer")
.kind(NodeKind::Cli)
.capability(PeerTransferServer(receiving.clone()))
.build()
{
Ok(node) => node,
Err(error) => {
tracing::error!(%error, "could not build the peer-side node");
return;
}
};
let options = AcceptOptions { node_id, ..AcceptOptions::default() };
let connection = match node.accept(transport, options).await {
Ok(connection) => connection,
Err(error) => {
tracing::warn!(%peer, %error, "the zyris handshake failed; closing");
return;
}
};
match connection.wait_capability(CAPABILITY_WAIT).await {
Ok(client) => receiving.set_peer(client),
Err(error) => {
tracing::warn!(%peer, %slug, %error, "the peer never announced peer_transfer; it cannot send to us");
}
}
connection.closed().await;
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn entry(slug: &str, endpoint_id: &str) -> ZPeerEntry {
ZPeerEntry {
node_id: format!("node-{slug}"),
slug: slug.to_string(),
endpoint_id: endpoint_id.to_string(),
online: true,
}
}
struct Directory {
entries: Mutex<Vec<ZPeerEntry>>,
calls: AtomicUsize,
}
impl Directory {
fn new(entries: Vec<ZPeerEntry>) -> Arc<Directory> {
Arc::new(Directory { entries: Mutex::new(entries), calls: AtomicUsize::new(0) })
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait::async_trait]
impl PeerDirectory for Directory {
async fn peers(&self) -> Result<Vec<ZPeerEntry>> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(self.entries.lock().await.clone())
}
}
#[tokio::test]
async fn a_node_of_this_account_is_found_and_the_list_is_fetched_once() {
let directory = Directory::new(vec![entry("laptop", "abc")]);
let mut cache =
PeerCache::new(directory.clone(), Duration::from_secs(60), Duration::from_secs(10));
assert_eq!(cache.find("abc").await.unwrap().slug, "laptop");
assert_eq!(cache.find("abc").await.unwrap().slug, "laptop");
assert_eq!(directory.calls(), 1, "a hit inside the TTL must not re-ask");
}
#[tokio::test]
async fn an_unknown_peer_cannot_force_a_fetch_per_connection() {
let directory = Directory::new(vec![entry("laptop", "abc")]);
let mut cache =
PeerCache::new(directory.clone(), Duration::from_secs(60), Duration::from_secs(10));
for _ in 0..20 {
assert!(cache.find("stranger").await.is_none());
}
assert_eq!(directory.calls(), 1, "twenty unknown peers, one fetch");
}
#[tokio::test]
async fn a_node_that_appears_after_the_first_fetch_is_found_once_the_interval_passes() {
let directory = Directory::new(vec![entry("laptop", "abc")]);
let mut cache =
PeerCache::new(directory.clone(), Duration::from_secs(60), Duration::ZERO);
assert!(cache.find("def").await.is_none());
directory.entries.lock().await.push(entry("desk", "def"));
assert_eq!(cache.find("def").await.unwrap().slug, "desk");
}
#[tokio::test]
async fn a_revoked_node_stops_being_found_after_a_refresh() {
let directory = Directory::new(vec![entry("laptop", "abc"), entry("desk", "def")]);
let mut cache = PeerCache::new(directory.clone(), Duration::ZERO, Duration::ZERO);
assert!(cache.find("def").await.is_some());
directory.entries.lock().await.retain(|e| e.endpoint_id != "def");
assert!(cache.find("def").await.is_none(), "a revoked node must not survive in the cache");
}
#[tokio::test]
async fn a_failed_refresh_keeps_the_list_it_already_had() {
struct Failing {
calls: AtomicUsize,
}
#[async_trait::async_trait]
impl PeerDirectory for Failing {
async fn peers(&self) -> Result<Vec<ZPeerEntry>> {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
Ok(vec![entry("laptop", "abc")])
} else {
Err(zyris::WireError::internal("the rendezvous is unreachable".to_string()))
}
}
}
let directory = Arc::new(Failing { calls: AtomicUsize::new(0) });
let mut cache = PeerCache::new(directory, Duration::ZERO, Duration::ZERO);
assert!(cache.find("abc").await.is_some());
assert!(cache.find("abc").await.is_some(), "a failed fetch must not empty the list");
}
}