core: iroh spike milestone 3 — discovery via iroh-gossip

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>
This commit is contained in:
2026-06-22 00:19:22 -05:00
parent 6645f6f37f
commit 106e1283cc
4 changed files with 222 additions and 2 deletions

View File

@@ -336,8 +336,16 @@ and crypto and getting it subtly wrong.
- [ ] Milestone 2b — true cross-NAT between two machines on different
networks; then stand up the homelab as a self-hosted iroh relay
(supernode #1) and repeat against it instead of n0's.
- [ ] Milestone 3 — discovery: map account/room → `EndpointId`
(`iroh-gossip` topic, or DNS/pkarr), replacing the SSE presence bus.
- [x] **Milestone 3 — discovery via `iroh-gossip`.**
`examples/iroh_gossip.rs`: two endpoints derive the same topic from a
shared account (`TopicId = sha256(account)` — its first 4 bytes are
today's `room_for_account`, so the models line up), join, and learn
each other's `EndpointId` by broadcasting presence — zero hardcoded
addresses. Bootstrap = knowing one peer's `EndpointId`; iroh's
discovery (n0 DNS here, a rendezvous supernode in prod) maps id→addr.
A `GossipPresence` source would emit `MeshEvent::Present`/`Discovered`,
retiring the SSE presence bus. Confirmed: gossip compiles to WASM, so
the web client uses the same discovery unchanged (§6.3).
- [ ] Milestone 4 — wrap as `IrohTransport` behind the `Transport`/
`MeshEvent` contract; A/B against RTC+LAN on latency & connect rate.
- [ ] Spike **Automerge** as the Sync tier; re-express the clipboard as a tiny

78
core/Cargo.lock generated
View File

@@ -419,6 +419,9 @@ name = "bytes"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
dependencies = [
"serde",
]
[[package]]
name = "camino"
@@ -1247,6 +1250,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flume"
version = "0.11.1"
@@ -1326,6 +1335,19 @@ dependencies = [
"futures-sink",
]
[[package]]
name = "futures-concurrency"
version = "7.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6"
dependencies = [
"fixedbitset",
"futures-core",
"futures-lite",
"pin-project",
"smallvec",
]
[[package]]
name = "futures-core"
version = "0.3.32"
@@ -2097,6 +2119,33 @@ dependencies = [
"url",
]
[[package]]
name = "iroh-gossip"
version = "0.101.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e1dc4b05f73e7a1b9e83b531eb63c3fd671b0af3aeb13b59c546dd7ca747515"
dependencies = [
"blake3",
"bytes",
"data-encoding",
"derive_more",
"futures-concurrency",
"hex",
"indexmap",
"iroh",
"iroh-base",
"iroh-metrics",
"irpc",
"n0-error",
"n0-future",
"postcard",
"rand 0.10.1",
"serde",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "iroh-metrics"
version = "1.0.1"
@@ -2170,6 +2219,33 @@ dependencies = [
"ws_stream_wasm",
]
[[package]]
name = "irpc"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3623d6ff582b415904b29bbe6ebcb4a4f9a262ccdee05a45fdd003ef0950c386"
dependencies = [
"futures-util",
"irpc-derive",
"n0-error",
"n0-future",
"serde",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "irpc-derive"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35c254013736de16472140d26904e6ac98e8f3887284dcf4af40f88c77411b56"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -2460,6 +2536,7 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895"
dependencies = [
"anyhow",
"n0-error-macros",
"spez",
]
@@ -4331,6 +4408,7 @@ dependencies = [
"futures-util",
"getrandom 0.4.3",
"iroh",
"iroh-gossip",
"mdns-sd",
"reqwest 0.12.28",
"serde",

View File

@@ -34,4 +34,5 @@ getrandom = "0.4.3"
# Iroh spike (examples/iroh_spike.rs): evaluate iroh as an alternative Mesh —
# QUIC P2P + relay holepunch + key-based identity. Dev-only; not in the shipped lib.
iroh = "1"
iroh-gossip = "0.101"
anyhow = "1"

View File

@@ -0,0 +1,133 @@
//! 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(())
}