core: iroh spike milestone 2b — cross-NAT two-machine tester
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>
This commit is contained in:
@@ -351,9 +351,13 @@ and crypto and getting it subtly wrong.
|
||||
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 2b — true cross-NAT between two machines on different
|
||||
networks. **Tester ready** (`examples/iroh_p2p.rs`): `accept` on one
|
||||
machine prints its `EndpointId`; `connect <id>` from a second network
|
||||
resolves via discovery, round-trips, and reports DIRECT (holepunch)
|
||||
vs RELAY (fallback). Same-host smoke test passes; **awaiting a
|
||||
two-network run** (Mac ⇄ phone-hotspot laptop). Then stand up the
|
||||
homelab as a self-hosted iroh relay (supernode #1) and repeat.
|
||||
- [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
|
||||
|
||||
120
core/examples/iroh_p2p.rs
Normal file
120
core/examples/iroh_p2p.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
//! 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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user