//! Internet peer discovery via pkarr relay servers. //! //! Derives deterministic keypair "slots" from the shared passphrase. //! Each peer claims a slot by publishing its EndpointId as a TXT record. //! All peers scan all slots periodically to discover each other. //! //! This works over the internet — no LAN, no port forwarding needed. //! Uses n0's public pkarr relay servers (same infrastructure as iroh). use std::collections::HashSet; use std::time::Duration; use anyhow::{Context, Result}; use iroh::EndpointId; use pkarr::{Client as PkarrClient, Keypair, SignedPacket}; use simple_dns::rdata::RData; use tokio::sync::mpsc; use tracing::{debug, info, warn}; const NUM_SLOTS: usize = 8; const PUBLISH_INTERVAL: Duration = Duration::from_secs(60); const SCAN_INTERVAL: Duration = Duration::from_secs(15); const RECORD_NAME: &str = "_can_sync"; /// Derive a deterministic pkarr keypair for a given slot index. fn derive_slot_keypair(passphrase: &str, slot: usize) -> Keypair { let seed = blake3::hash( format!("can-sync-rendezvous:{}:{}", passphrase, slot).as_bytes(), ); let seed_bytes: [u8; 32] = *seed.as_bytes(); let secret = ed25519_dalek::SecretKey::from(seed_bytes); Keypair::from_secret_key(&secret) } /// Internet peer discovery via pkarr relay. pub struct Rendezvous { slots: Vec, our_id: EndpointId, client: PkarrClient, } impl Rendezvous { /// Create a new Rendezvous by deriving keypairs for all slots from the passphrase. pub fn new(passphrase: &str, our_id: EndpointId) -> Result { let slots: Vec = (0..NUM_SLOTS) .map(|i| derive_slot_keypair(passphrase, i)) .collect(); let client = PkarrClient::builder() .build() .context("creating pkarr client")?; Ok(Self { slots, our_id, client, }) } /// Run the rendezvous loop: claim a slot, periodically re-publish and scan. pub async fn run(self, tx: mpsc::Sender) -> Result<()> { let our_id_hex = hex::encode(self.our_id.as_bytes()); info!( "Rendezvous: starting internet discovery ({} slots, publish every {}s, scan every {}s)", NUM_SLOTS, PUBLISH_INTERVAL.as_secs(), SCAN_INTERVAL.as_secs(), ); // Claim our slot (first empty, or hash-based fallback) let our_slot = self.claim_slot(&our_id_hex).await; info!("Rendezvous: claimed slot {}", our_slot); let mut known_peers: HashSet = HashSet::new(); let mut publish_tick = tokio::time::interval(PUBLISH_INTERVAL); let mut scan_tick = tokio::time::interval(SCAN_INTERVAL); // Do an initial scan immediately self.scan_all_slots(&mut known_peers, &tx).await; loop { tokio::select! { _ = publish_tick.tick() => { if let Err(e) = self.publish_slot(our_slot, &our_id_hex).await { warn!("Rendezvous: failed to re-publish slot {}: {:#}", our_slot, e); } } _ = scan_tick.tick() => { self.scan_all_slots(&mut known_peers, &tx).await; } } } } // Read every slot and report any newly discovered peer IDs. async fn scan_all_slots( &self, known_peers: &mut HashSet, tx: &mpsc::Sender, ) { for i in 0..NUM_SLOTS { match self.read_slot(i).await { Some(peer_id) if peer_id != self.our_id && known_peers.insert(peer_id) => { info!( "Rendezvous: discovered peer {} in slot {}", peer_id.fmt_short(), i ); let _ = tx.send(peer_id).await; } _ => {} } } } // Pick an available slot for this peer: reuse our old slot, take an empty one, // or fall back to a deterministic slot based on our ID. async fn claim_slot(&self, our_id_hex: &str) -> usize { // Check if we already own a slot (from a previous run) for i in 0..NUM_SLOTS { if let Some(peer_id) = self.read_slot(i).await { if peer_id == self.our_id { debug!("Rendezvous: already own slot {}", i); return i; } } } // Claim first empty slot for i in 0..NUM_SLOTS { if self.read_slot(i).await.is_none() { if let Err(e) = self.publish_slot(i, our_id_hex).await { warn!("Rendezvous: failed to claim slot {}: {:#}", i, e); continue; } return i; } } // All slots occupied — use deterministic slot based on our ID let slot = { let h = blake3::hash(self.our_id.as_bytes()); let bytes: [u8; 8] = h.as_bytes()[..8].try_into().unwrap(); u64::from_le_bytes(bytes) as usize % NUM_SLOTS }; let _ = self.publish_slot(slot, our_id_hex).await; slot } // Write our EndpointId into the given slot's DNS TXT record via the pkarr relay. async fn publish_slot(&self, slot: usize, our_id_hex: &str) -> Result<()> { let keypair = &self.slots[slot]; let packet = SignedPacket::builder() .txt( RECORD_NAME.try_into().context("invalid record name")?, our_id_hex.try_into().context("invalid txt value")?, 300, // 5 min TTL ) .sign(keypair) .context("signing pkarr packet")?; self.client .publish(&packet, None) .await .context("publishing to pkarr relay")?; debug!("Rendezvous: published slot {}", slot); Ok(()) } // Look up a slot's DNS TXT record and parse the EndpointId stored there, if any. async fn read_slot(&self, slot: usize) -> Option { let public_key = self.slots[slot].public_key(); let packet = self.client.resolve(&public_key).await?; // Use pkarr's resource_records iterator to find our TXT record for record in packet.resource_records(RECORD_NAME) { if let RData::TXT(txt) = &record.rdata { // Try to extract the hex-encoded EndpointId from TXT attributes if let Ok(txt_string) = String::try_from(txt.clone()) { let hex_str = txt_string.trim(); if let Ok(bytes) = hex::decode(hex_str) { if bytes.len() == 32 { if let Ok(arr) = <[u8; 32]>::try_from(bytes.as_slice()) { return EndpointId::from_bytes(&arr).ok(); } } } } } } None } }