From 1788bf7a37d47046761848cc5f4fc86d9039fbe8 Mon Sep 17 00:00:00 2001 From: Patrick Ecord Date: Mon, 22 Jun 2026 00:01:45 -0500 Subject: [PATCH] =?UTF-8?q?core:=20iroh=20spike=20milestone=202=20?= =?UTF-8?q?=E2=80=94=20prove=20n0's=20hosted=20relay=20carries=20us?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/iroh_relay.rs forces the relay path on one machine: the connect side turns address lookup OFF and dials a relay-only EndpointAddr (id + home relay URL, no direct IPs), so the connection can only bootstrap through n0's hosted relay. Verified round-trip via use1-1.relay.n0.iroh.link; remote_info then shows a DCUtR upgrade to a direct path (relay + direct both Active) — so this proves both the relay bootstrap and the holepunch upgrade in one run. Remaining for cross-network confidence (milestone 2b): two machines on different networks, then repeat against a self-hosted homelab relay (supernode #1). Co-Authored-By: Claude Opus 4.8 --- ARCHITECTURE.md | 11 +++- core/examples/iroh_relay.rs | 101 ++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 core/examples/iroh_relay.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5ce7176..9f0b403 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -313,8 +313,15 @@ and crypto and getting it subtly wrong. `connect()` ≈ the dial path; `connection.remote_id()` is a durable per-device public key — identity *is* the transport (no separate keypair/Noise layer needed, cf. §3). - - [ ] Milestone 2 — cross-network holepunch via a relay (two machines); - stand up the homelab as a self-hosted iroh relay (supernode #1). + - [x] **Milestone 2 — n0's hosted relay carries us.** + `examples/iroh_relay.rs`: connect side with address lookup OFF + a + relay-only `EndpointAddr` → the connection *had* to bootstrap through + n0's relay (`use1-1.relay.n0.iroh.link`). `remote_info` then showed a + DCUtR upgrade to a direct path (relay + direct both Active) — relay + bootstrap *and* holepunch upgrade, on one machine. + - [ ] 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. - [ ] Milestone 4 — wrap as `IrohTransport` behind the `Transport`/ diff --git a/core/examples/iroh_relay.rs b/core/examples/iroh_relay.rs new file mode 100644 index 0000000..ca7fcae --- /dev/null +++ b/core/examples/iroh_relay.rs @@ -0,0 +1,101 @@ +//! Iroh spike, milestone 2 — prove n0's HOSTED RELAY carries our traffic. +//! cargo run --example iroh_relay +//! +//! Milestone 1 (iroh_spike.rs) connected two endpoints on loopback — that could +//! have gone direct. This forces the relay path so we actually exercise n0's +//! hosted infra (the reliability floor that makes cross-network work when +//! holepunch fails): +//! - the connecting endpoint has address lookup turned OFF, so it CANNOT +//! discover the server's direct socket addresses, and +//! - it is handed a RELAY-ONLY EndpointAddr (id + home relay URL, no IPs). +//! With no other path available, a successful round-trip can only have traversed +//! n0's relay. `remote_info` is printed to show the path actually in use. +//! +//! Honest scope: this proves the *relay carries data*, on one machine. It does +//! NOT prove cross-NAT holepunch+upgrade — that needs two machines on different +//! networks (milestone 2b, and the homelab-as-relay step). + +use iroh::{ + endpoint::{presets, Connection}, + protocol::{AcceptError, ProtocolHandler, Router}, + Endpoint, EndpointAddr, +}; + +const ALPN: &[u8] = b"tether/iroh-relay/0"; +const PAYLOAD: &[u8] = b"tether clipboard, carried over n0's hosted relay"; + +fn ae(e: E) -> anyhow::Error { + anyhow::anyhow!("{e}") +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // ── accept side: full N0 (relay + address publishing) ──────────────────── + let server = Endpoint::bind(presets::N0).await.map_err(ae)?; + let server_id = server.id(); + let router = Router::builder(server).accept(ALPN, Echo).spawn(); + router.endpoint().online().await; + let relay = router + .endpoint() + .addr() + .relay_urls() + .next() + .cloned() + .ok_or_else(|| anyhow::anyhow!("accept side has no home relay assigned"))?; + println!("[relay] accept side online — id={server_id}\n home relay = {relay}"); + + // ── connect side: n0 relay kept, address lookup OFF, relay-only target ──── + let client = Endpoint::builder(presets::N0) + .clear_address_lookup() + .bind() + .await + .map_err(ae)?; + client.online().await; + let relay_only = EndpointAddr::new(server_id).with_relay_url(relay); + println!( + "[relay] connect side id={} — dialing RELAY-ONLY (no direct addrs, lookup off)", + client.id() + ); + + let conn = client.connect(relay_only, ALPN).await.map_err(ae)?; + let (mut send, mut recv) = conn.open_bi().await.map_err(ae)?; + send.write_all(PAYLOAD).await.map_err(ae)?; + send.finish().map_err(ae)?; + let echoed = recv.read_to_end(64 * 1024).await.map_err(ae)?; + + // Show the path actually negotiated (relay vs any holepunched direct upgrade). + if let Some(info) = client.remote_info(server_id).await { + println!("[relay] remote_info after round-trip: {info:?}"); + } + + conn.close(0u32.into(), b"done"); + client.close().await; + router.shutdown().await.map_err(ae)?; + + anyhow::ensure!( + echoed == PAYLOAD, + "echo mismatch: {:?}", + String::from_utf8_lossy(&echoed) + ); + println!( + "✅ n0 hosted-relay round-trip: {} bytes (address lookup off + relay-only addr → \ + the relay had to carry it)", + echoed.len() + ); + Ok(()) +} + +/// Echo the first stream's bytes back (stands in for emitting MeshEvents). +#[derive(Debug, Clone)] +struct Echo; + +impl ProtocolHandler for Echo { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + let (mut send, mut recv) = connection.accept_bi().await?; + let n = tokio::io::copy(&mut recv, &mut send).await?; + println!("[relay] echoed {n} byte(s) back to {}", connection.remote_id()); + send.finish()?; + connection.closed().await; + Ok(()) + } +}