examples/iroh_p2p.rs: `accept` prints this node's EndpointId and echoes any stream; `connect <id>` resolves the peer by id via discovery (no addresses), round-trips, and reports DIRECT (holepunch worked) vs RELAY (fallback) from remote_info. This is the last unknown before M4 — M1/M2/M3 all ran same-host; this proves real cross-NAT. Same-host smoke test passes (DIRECT over loopback). Awaiting a genuine two-network run (Mac on wifi ⇄ laptop on phone hotspot). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
121 lines
4.8 KiB
Rust
121 lines
4.8 KiB
Rust
//! Iroh spike, milestone 2b — cross-NAT holepunch on TWO machines / networks.
|
|
//! Machine A (e.g. Mac on home wifi):
|
|
//! cargo run --example iroh_p2p -- accept
|
|
//! → prints this node's EndpointId, then waits, echoing any stream.
|
|
//! Machine B (e.g. laptop on phone hotspot / a different network):
|
|
//! cargo run --example iroh_p2p -- connect <ENDPOINT_ID_FROM_A>
|
|
//! → resolves A by id (n0 discovery), connects, round-trips, and prints
|
|
//! whether the path is DIRECT (holepunch worked) or RELAY (fell back).
|
|
//!
|
|
//! This is the last unknown before committing to iroh (M4): the M1/M2/M3 spikes
|
|
//! all ran same-host. Here the two endpoints are on different NATs, so a DIRECT
|
|
//! path in `remote_info` proves real holepunch; RELAY proves the fallback floor.
|
|
//! Either way the round-trip must succeed — that's the cross-network sync we
|
|
//! verified on the hand-rolled stack, now on iroh.
|
|
|
|
use std::time::Duration;
|
|
|
|
use iroh::{
|
|
endpoint::{presets, Connection},
|
|
protocol::{AcceptError, ProtocolHandler, Router},
|
|
Endpoint, EndpointAddr, EndpointId,
|
|
};
|
|
|
|
const ALPN: &[u8] = b"tether/iroh-p2p/0";
|
|
const PAYLOAD: &[u8] = b"tether cross-NAT round-trip over iroh";
|
|
|
|
fn ae<E: std::fmt::Display>(e: E) -> anyhow::Error {
|
|
anyhow::anyhow!("{e}")
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
let mode = std::env::args().nth(1).unwrap_or_default();
|
|
match mode.as_str() {
|
|
"accept" => accept().await,
|
|
"connect" => {
|
|
let id = std::env::args()
|
|
.nth(2)
|
|
.ok_or_else(|| anyhow::anyhow!("usage: iroh_p2p connect <ENDPOINT_ID>"))?;
|
|
connect(&id).await
|
|
}
|
|
_ => {
|
|
eprintln!("usage:\n iroh_p2p accept\n iroh_p2p connect <ENDPOINT_ID>");
|
|
std::process::exit(2);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn accept() -> anyhow::Result<()> {
|
|
let endpoint = Endpoint::bind(presets::N0).await.map_err(ae)?;
|
|
let router = Router::builder(endpoint.clone())
|
|
.accept(ALPN, Echo)
|
|
.spawn();
|
|
endpoint.online().await;
|
|
println!("════════════════════════════════════════════════════════════");
|
|
println!(" accept side ready. On the OTHER machine/network, run:");
|
|
println!(" cargo run --example iroh_p2p -- connect {}", endpoint.id());
|
|
println!("════════════════════════════════════════════════════════════");
|
|
println!("(waiting for a connection — Ctrl-C to stop)");
|
|
std::future::pending::<()>().await; // run until killed
|
|
let _ = router;
|
|
Ok(())
|
|
}
|
|
|
|
async fn connect(id_str: &str) -> anyhow::Result<()> {
|
|
let target: EndpointId = id_str
|
|
.trim()
|
|
.parse()
|
|
.map_err(|_| anyhow::anyhow!("invalid EndpointId: {id_str:?}"))?;
|
|
|
|
let endpoint = Endpoint::bind(presets::N0).await.map_err(ae)?;
|
|
endpoint.online().await;
|
|
println!("[p2p] dialing {target} (resolving address via discovery)…");
|
|
|
|
// Address-less target → iroh's discovery (n0 DNS/pkarr) resolves it, then
|
|
// holepunches; falls back to relay if the NATs won't cooperate.
|
|
let conn = endpoint
|
|
.connect(EndpointAddr::new(target), 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)?;
|
|
anyhow::ensure!(echoed == PAYLOAD, "echo mismatch");
|
|
|
|
// Give holepunch a moment to upgrade past the initial path, then report.
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
let path = match endpoint.remote_info(target).await {
|
|
Some(info) => format!("{info:?}"),
|
|
None => "(no remote_info)".to_string(),
|
|
};
|
|
let direct = path.contains("Ip(") && path.contains("Active");
|
|
println!(
|
|
"✅ cross-NAT round-trip OK ({} bytes). Path: {}\n remote_info: {}",
|
|
echoed.len(),
|
|
if direct { "DIRECT (holepunch worked)" } else { "RELAY (fallback floor)" },
|
|
path
|
|
);
|
|
|
|
conn.close(0u32.into(), b"done");
|
|
endpoint.close().await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Echo the first stream back (stands in for emitting MeshEvents).
|
|
#[derive(Debug, Clone)]
|
|
struct Echo;
|
|
|
|
impl ProtocolHandler for Echo {
|
|
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
|
println!("[p2p] inbound connection from {}", connection.remote_id());
|
|
let (mut send, mut recv) = connection.accept_bi().await?;
|
|
let n = tokio::io::copy(&mut recv, &mut send).await?;
|
|
println!("[p2p] echoed {n} byte(s)");
|
|
send.finish()?;
|
|
connection.closed().await;
|
|
Ok(())
|
|
}
|
|
}
|