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:
@@ -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
2395
core/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
93
core/examples/iroh_spike.rs
Normal file
93
core/examples/iroh_spike.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user