CanMan/examples/can-sync/src/rendezvous.rs
Jason Tudisco 620966872e Add plain-English comments to all functions across src/ and examples/
Comments help non-Rust users understand what each function, struct, and
module does. Covers the core service (18 source files) and all four
example projects (can-sync, canfs, filemanager, paste).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:35:24 -06:00

196 lines
6.9 KiB
Rust

//! 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<Keypair>,
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<Self> {
let slots: Vec<Keypair> = (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<EndpointId>) -> 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<EndpointId> = 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<EndpointId>,
tx: &mpsc::Sender<EndpointId>,
) {
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<EndpointId> {
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
}
}