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>
196 lines
6.9 KiB
Rust
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
|
|
}
|
|
}
|