examples/iroh_gossip.rs proves account → {EndpointId} the P2P way: two endpoints
derive the same gossip TopicId = sha256(account) (its first 4 bytes are today's
room_for_account, so the models line up), join the topic, and learn each other's
public keys by broadcasting presence — zero hardcoded addresses in steady state.
Bootstrap is the one fact gossip can't self-supply: the joiner needs one peer's
EndpointId, and iroh's discovery (n0 DNS here, a tiny rendezvous supernode in
production) maps id→addr. Everything after first contact is peer gossip. A
GossipPresence MeshEvent source would emit Present/Discovered, retiring the SSE
presence bus. iroh-gossip compiles to WASM, so the web client gets the same
discovery unchanged.
iroh-gossip 0.101 added as a dev-dependency (tracks iroh ^1). Verified:
`cargo run --example iroh_gossip` → "✅ gossip discovery: both endpoints found
each other ... with zero hardcoded addresses", clean graceful shutdown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
134 lines
5.1 KiB
Rust
134 lines
5.1 KiB
Rust
//! Iroh spike, milestone 3 — discovery via iroh-gossip (replaces the SSE presence bus).
|
|
//! cargo run --example iroh_gossip
|
|
//!
|
|
//! Proves `account → {EndpointId}` the P2P way: two endpoints derive the SAME
|
|
//! gossip topic from a shared account secret (exactly like room_for_account
|
|
//! today), join it, and learn each other's public keys by broadcasting presence
|
|
//! — no hardcoded peer addresses in the steady state.
|
|
//!
|
|
//! The ONE thing gossip can't do for itself is bootstrap: to join topic T you
|
|
//! must reach ≥1 peer already in T. Here we hand the joiner the opener's address
|
|
//! directly — in production that single fact comes from a tiny rendezvous
|
|
//! supernode (the homelab), which is all the server shrinks to. Everything after
|
|
//! the first contact is peer-to-peer gossip.
|
|
//!
|
|
//! Maps onto the engine: a `GossipPresence` source would emit `MeshEvent::Present`
|
|
//! / `MeshEvent::Discovered` from received presence, replacing rtc.rs's chirps.
|
|
|
|
use std::time::Duration;
|
|
|
|
use futures_util::TryStreamExt;
|
|
use iroh::{protocol::Router, Endpoint, endpoint::presets};
|
|
use iroh_gossip::{api::Event, net::Gossip, net::GOSSIP_ALPN, proto::TopicId};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
fn ae<E: std::fmt::Display>(e: E) -> anyhow::Error {
|
|
anyhow::anyhow!("{e}")
|
|
}
|
|
|
|
/// account → 32-byte gossip topic (same idea as room_for_account, full width).
|
|
fn topic_for_account(account: &str) -> TopicId {
|
|
use sha2::{Digest, Sha256};
|
|
let mut id = [0u8; 32];
|
|
id.copy_from_slice(&Sha256::digest(account.as_bytes()));
|
|
TopicId::from_bytes(id)
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
struct Presence {
|
|
id: String, // the broadcaster's EndpointId (author, carried in payload —
|
|
name: String, // gossip relays, so don't trust delivered_from for identity)
|
|
}
|
|
|
|
/// Build an endpoint + a gossip instance registered on a Router.
|
|
async fn node() -> anyhow::Result<(Endpoint, Gossip, Router)> {
|
|
let endpoint = Endpoint::bind(presets::N0).await.map_err(ae)?;
|
|
endpoint.online().await;
|
|
let gossip = Gossip::builder().spawn(endpoint.clone());
|
|
let router = Router::builder(endpoint.clone())
|
|
.accept(GOSSIP_ALPN, gossip.clone())
|
|
.spawn();
|
|
Ok((endpoint, gossip, router))
|
|
}
|
|
|
|
/// Join the topic, broadcast our presence, and wait until we hear the peer's.
|
|
async fn discover(
|
|
label: &'static str,
|
|
endpoint: Endpoint,
|
|
gossip: Gossip,
|
|
topic: TopicId,
|
|
bootstrap: Vec<iroh::EndpointId>,
|
|
) -> anyhow::Result<String> {
|
|
let me = Presence { id: endpoint.id().to_string(), name: label.to_string() };
|
|
let payload = serde_json::to_vec(&me)?;
|
|
|
|
let (sender, mut receiver) = gossip
|
|
.subscribe_and_join(topic, bootstrap)
|
|
.await
|
|
.map_err(ae)?
|
|
.split();
|
|
println!("[gossip] {label} joined topic — id={}", me.id);
|
|
|
|
// Re-broadcast on a ticker so a late-joining neighbor still hears us.
|
|
let ticker = {
|
|
let sender = sender.clone();
|
|
let payload = payload.clone();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let _ = sender.broadcast(payload.clone().into()).await;
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
}
|
|
})
|
|
};
|
|
|
|
let found = async {
|
|
while let Some(event) = receiver.try_next().await.map_err(ae)? {
|
|
if let Event::Received(msg) = event {
|
|
if let Ok(p) = serde_json::from_slice::<Presence>(&msg.content) {
|
|
if p.id != me.id {
|
|
return Ok::<String, anyhow::Error>(format!("{} ({})", p.name, p.id));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
anyhow::bail!("{label}: gossip stream ended before discovering a peer")
|
|
};
|
|
|
|
let peer = tokio::time::timeout(Duration::from_secs(20), found)
|
|
.await
|
|
.map_err(|_| anyhow::anyhow!("{label}: timed out waiting to discover a peer"))??;
|
|
ticker.abort();
|
|
println!("[gossip] {label} discovered peer via gossip: {peer}");
|
|
Ok(peer)
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
let account = "pecord@gmail.com"; // both derive the same topic from this
|
|
let topic = topic_for_account(account);
|
|
println!("[gossip] account {account:?} → topic {topic}");
|
|
|
|
// Opener and joiner.
|
|
let (a_ep, a_gossip, a_router) = node().await?;
|
|
let (b_ep, b_gossip, b_router) = node().await?;
|
|
|
|
// Bootstrap: the joiner only needs the opener's EndpointId; iroh's own
|
|
// discovery (n0 DNS/pkarr here, a rendezvous supernode in production) maps
|
|
// that id → address. Everything after first contact is peer gossip.
|
|
let a_id = a_ep.id();
|
|
|
|
let a = discover("node-A", a_ep, a_gossip, topic, vec![]);
|
|
let b = discover("node-B", b_ep, b_gossip, topic, vec![a_id]);
|
|
let (a_found, b_found) = tokio::try_join!(a, b)?;
|
|
|
|
// Graceful teardown so background gossip/accept tasks stop cleanly.
|
|
a_router.shutdown().await.map_err(ae)?;
|
|
b_router.shutdown().await.map_err(ae)?;
|
|
|
|
println!(
|
|
"✅ gossip discovery: both endpoints found each other in the \
|
|
account-derived topic with zero hardcoded addresses\n A↔{a_found}\n B↔{b_found}"
|
|
);
|
|
Ok(())
|
|
}
|