core: iroh spike — prove iroh 1.0 as an alternative Mesh (milestone 1)

De-risks the Iroh evaluation before any integration. examples/iroh_spike.rs
stands up two iroh 1.0 endpoints in-process, each identified by a public-key
EndpointId, and round-trips bytes over a QUIC bidirectional stream:
  - accept side via Router + ProtocolHandler (≈ DirectUp + inbound MeshEvents)
  - connect side via endpoint.connect(addr, ALPN) (≈ the LAN/RTC dial path)
  - connection.remote_id() gives a durable per-device public key — identity is
    the transport, so iroh subsumes the keypair/Noise layer from ARCHITECTURE §3

iroh is a dev-dependency only (not in the shipped lib). The API maps cleanly
onto the Transport/MeshEvent seam, so an eventual IrohTransport slots in without
touching the Sync/App tiers.

Verified: `cargo run --example iroh_spike` → " iroh round-trip: 49 bytes
echoed P2P (QUIC, key-addressed)". Roadmap updated with milestones 2–4
(cross-network relay via homelab supernode, account→EndpointId discovery,
IrohTransport wrap + A/B vs RTC+LAN).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 23:49:22 -05:00
parent da0d0365e4
commit 68738dd82c
4 changed files with 2476 additions and 32 deletions

View File

@@ -304,9 +304,21 @@ and crypto and getting it subtly wrong.
events, engine owns state. Fixed the transport→sync coupling and the
stringly-typed capability check; verified behavior-preserving over LAN +
crypto round-trip. *Prerequisite for the Iroh spike — done.*
- [ ] Spike **Iroh** as the Mesh — homelab as self-hosted relay/bootstrap
- [~] Spike **Iroh** as the Mesh — homelab as self-hosted relay/bootstrap
(supernode #1). Compare against the current hand-rolled RTC/LAN/SSE.
(Drops in as a `MeshEvent` source once the seam exists.)
- [x] **Milestone 1 — it builds & connects.** `examples/iroh_spike.rs`:
iroh 1.0, two key-addressed endpoints, QUIC bi-stream round-trip
in-process. `ProtocolHandler::accept``DirectUp` + inbound events;
`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).
- [ ] 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`/
`MeshEvent` contract; A/B against RTC+LAN on latency & connect rate.
- [ ] Spike **Automerge** as the Sync tier; re-express the clipboard as a tiny
app over replicated state to validate the App API.
- [ ] Durable device keypairs + authenticated handshake (Noise/`snow`, or

2395
core/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,3 +29,9 @@ bytes = "1.12.0"
chacha20poly1305 = "0.10.1"
base64 = "0.22.1"
getrandom = "0.4.3"
[dev-dependencies]
# 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"
anyhow = "1"

View File

@@ -0,0 +1,93 @@
//! Iroh spike — does iroh 1.0 give us the Mesh for free?
//! cargo run --example iroh_spike
//!
//! Proves, in-process, the three things a tether Mesh transport needs:
//! 1. an Endpoint identified by a public key (durable device identity — the
//! thing ARCHITECTURE.md §3 wants, here for free as `EndpointId`),
//! 2. an accept side (QUIC + relay holepunch handled by iroh),
//! 3. a connect side that dials by address and round-trips bytes.
//!
//! Mapping onto our contract (so the integration is obvious):
//! - `ProtocolHandler::accept(conn)` ≈ a peer became directly reachable
//! → emit `MeshEvent::DirectUp(remote_id)`, then read frames and emit
//! `MeshEvent::Message` / `MeshEvent::File` just like rtc.rs/lan.rs do.
//! - `endpoint.connect(addr, ALPN)` ≈ the LAN/RTC dial path (lower id dials).
//! - `connection.remote_id()` ≈ a stable per-device public key — no
//! separate keypair/Noise layer needed; identity is the transport.
//!
//! What this spike does NOT yet prove: cross-network holepunch via a relay
//! (needs two machines), and discovery (mapping an account/room → EndpointId).
//! Those are the next milestones if iroh earns the integration.
use iroh::{
endpoint::{presets, Connection},
protocol::{AcceptError, ProtocolHandler, Router},
Endpoint, EndpointAddr,
};
const ALPN: &[u8] = b"tether/iroh-spike/0";
const PAYLOAD: &[u8] = b"tether clipboard sync, carried over iroh 1.0 QUIC";
/// Turn any Display error into anyhow, so we don't pull in iroh's n0-error crate
/// just for a spike.
fn ae<E: std::fmt::Display>(e: E) -> anyhow::Error {
anyhow::anyhow!("{e}")
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// ── accept side ──────────────────────────────────────────────────────────
let server = Endpoint::bind(presets::N0).await.map_err(ae)?;
let server_id = server.id();
let router = Router::builder(server).accept(ALPN, Echo).spawn();
// Wait until the endpoint has a reachable address to hand out.
router.endpoint().online().await;
let server_addr: EndpointAddr = router.endpoint().addr();
println!("[iroh] accept side online — id={server_id}");
// ── connect side ─────────────────────────────────────────────────────────
let client = Endpoint::bind(presets::N0).await.map_err(ae)?;
println!("[iroh] connect side — id={}", client.id());
let conn = client.connect(server_addr, 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)?;
// Echo handler sends the same bytes back.
let echoed = recv.read_to_end(64 * 1024).await.map_err(ae)?;
conn.close(0u32.into(), b"done");
client.close().await;
router.shutdown().await.map_err(ae)?;
if echoed == PAYLOAD {
println!(
"✅ iroh round-trip: {} bytes echoed P2P (QUIC, key-addressed)\n payload={:?}",
echoed.len(),
String::from_utf8_lossy(&echoed)
);
Ok(())
} else {
anyhow::bail!("echo mismatch: got {:?}", String::from_utf8_lossy(&echoed))
}
}
/// Minimal accepting protocol: echo the first stream's bytes back. In the real
/// transport this body would emit MeshEvents instead of echoing.
#[derive(Debug, Clone)]
struct Echo;
impl ProtocolHandler for Echo {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let remote = connection.remote_id();
println!("[iroh] accepted connection from {remote}");
let (mut send, mut recv) = connection.accept_bi().await?;
// ≈ read frames → emit MeshEvent::Message/File. Here: echo straight back.
let n = tokio::io::copy(&mut recv, &mut send).await?;
println!("[iroh] echoed {n} byte(s) back to {remote}");
send.finish()?;
connection.closed().await;
Ok(())
}
}