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:
@@ -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
78
core/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
133
core/examples/iroh_gossip.rs
Normal file
133
core/examples/iroh_gossip.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user