Proof-of-concept mesh agent library over iroh
Working library with real iroh connections, not an interface sketch: - persistent device identity in state.sqlite, stable across restarts - deterministic network space derived from name + secret via HKDF-SHA256, with frozen labels and unambiguous length-prefixed encoding - replaceable discovery returning unverified candidates only; static bootstrap, in-memory test backend and a composite - real iroh connections plus an explicit mutual membership proof: HMAC-SHA256 over a role-separated transcript bound to the TLS exporter, the network id and both endpoint identities - small versioned control protocol: handshake, announcement, ping/pong - multiple networks per agent with enforced isolation - automatic reconnect with bounded backoff and jitter - mandatory state vs disposable cache, with a real directory ownership lock - status snapshots, event stream and honest diagnostics 47 integration and unit tests cover the required scenarios offline on loopback. Snapshots, revocations and WireGuard are designed for and documented, not implemented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
//! A tiny runnable demonstration of the library.
|
||||
//!
|
||||
//! Run it with:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --example two_agents
|
||||
//! ```
|
||||
//!
|
||||
//! It starts two agents in one process, on loopback only, joins them to the
|
||||
//! same network space and exchanges one request/response. It is a demo, not a
|
||||
//! substitute for the integration tests in `tests/`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tsunagi::agent::Event;
|
||||
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::{NetworkName, NetworkSecret};
|
||||
use tsunagi::proto::ControlMessage;
|
||||
use tsunagi::{Agent, Result};
|
||||
|
||||
fn config(root: &std::path::Path, discovery: &SharedMemoryDiscovery) -> AgentConfig {
|
||||
AgentConfig::new(StoragePaths::under(root))
|
||||
// Loopback only: no relays, no address lookup, no port mapping.
|
||||
.with_transport(TransportPolicy::LocalOnly)
|
||||
.with_loopback_bind()
|
||||
.with_discovery(Arc::new(discovery.clone()))
|
||||
.with_discovery_interval(Duration::from_millis(200))
|
||||
}
|
||||
|
||||
// The library never starts a runtime of its own; the binary owns it.
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// The library never installs a global subscriber either.
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let root = match tempfile::TempDir::new() {
|
||||
Ok(root) => root,
|
||||
Err(err) => {
|
||||
eprintln!("cannot create a temporary directory: {err}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
|
||||
let alice =
|
||||
Agent::spawn(config(&root.path().join("alice"), &discovery).with_hostname("alice")).await?;
|
||||
let bob =
|
||||
Agent::spawn(config(&root.path().join("bob"), &discovery).with_hostname("bob")).await?;
|
||||
|
||||
// The end user supplies exactly these two values.
|
||||
let name = NetworkName::new("kitchen-table")?;
|
||||
let secret = NetworkSecret::generate();
|
||||
println!(
|
||||
"network secret (keep it safe): {}",
|
||||
secret.encode().as_str()
|
||||
);
|
||||
|
||||
let network = alice.join_network(&name, &secret).await?;
|
||||
let same = bob.join_network(&name, &secret).await?;
|
||||
// The same name and secret always derive the same network space.
|
||||
if network != same {
|
||||
eprintln!("network derivation is not deterministic; this is a bug");
|
||||
return Ok(());
|
||||
}
|
||||
println!("network id: {network}");
|
||||
println!("alice: {}", alice.endpoint_id());
|
||||
println!("bob: {}", bob.endpoint_id());
|
||||
|
||||
let mut events = alice.subscribe();
|
||||
loop {
|
||||
match events.recv().await {
|
||||
Ok(Event::PeerConnected {
|
||||
peer,
|
||||
role,
|
||||
transport,
|
||||
rtt,
|
||||
..
|
||||
}) => {
|
||||
println!("alice authenticated {peer} as {role:?} over {transport:?} rtt={rtt:?}");
|
||||
break;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
eprintln!("event stream ended: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alice
|
||||
.send(
|
||||
network,
|
||||
bob.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: 1,
|
||||
payload: b"hello".to_vec(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
while let Ok(event) = events.recv().await {
|
||||
if let Event::MessageReceived {
|
||||
peer,
|
||||
message: ControlMessage::Pong { seq, payload },
|
||||
..
|
||||
} = event
|
||||
{
|
||||
println!(
|
||||
"pong from {peer}: seq={seq} payload={:?}",
|
||||
String::from_utf8_lossy(&payload)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let status = alice.status().await?;
|
||||
println!("\nalice status:");
|
||||
println!(" hostname {}", status.hostname);
|
||||
println!(" bound sockets {:?}", status.bound_sockets);
|
||||
println!(" cache {:?}", status.cache_outcome);
|
||||
for net in &status.networks {
|
||||
println!(" network {} ({:?})", net.name, net.state);
|
||||
for peer in &net.peers {
|
||||
println!(
|
||||
" peer {} hostname={:?} transport={:?} rtt={:?}",
|
||||
peer.endpoint_id, peer.hostname, peer.transport, peer.rtt
|
||||
);
|
||||
for path in &peer.paths {
|
||||
println!(
|
||||
" path {:?} selected={} rtt={:?}",
|
||||
path.remote, path.is_selected, path.rtt
|
||||
);
|
||||
}
|
||||
}
|
||||
println!(" metrics {:?}", net.metrics);
|
||||
}
|
||||
|
||||
alice.shutdown().await;
|
||||
bob.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user