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>
142 lines
4.9 KiB
Rust
142 lines
4.9 KiB
Rust
//! Discovery backends: static bootstrap candidates, composition, and the fact
|
|
//! that discovery only ever supplies *candidates*.
|
|
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
mod common;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use common::{TestAgent, local_config, network, settle, wait_for_peers};
|
|
use tsunagi::Agent;
|
|
use tsunagi::discovery::{
|
|
CandidateSource, CompositeDiscovery, NetworkDiscovery, SharedMemoryDiscovery, StaticBootstrap,
|
|
};
|
|
use tsunagi::identity::NetworkKeys;
|
|
|
|
#[tokio::test]
|
|
async fn a_static_bootstrap_candidate_is_enough_to_join() {
|
|
let (name, secret) = network("bootstrap-only");
|
|
|
|
// The listener runs with no discovery at all: it only accepts.
|
|
let listener_dir = tempfile::TempDir::new().unwrap();
|
|
let listener = Agent::spawn(local_config(listener_dir.path()))
|
|
.await
|
|
.unwrap();
|
|
let network_id = listener.join_network(&name, &secret).await.unwrap();
|
|
|
|
// The joiner is handed the listener's iroh id and addresses up front, which
|
|
// is exactly what a static bootstrap entry is.
|
|
let bootstrap = Arc::new(StaticBootstrap::new([listener.local_addr()]));
|
|
let joiner_dir = tempfile::TempDir::new().unwrap();
|
|
let joiner = Agent::spawn(local_config(joiner_dir.path()).with_discovery(bootstrap))
|
|
.await
|
|
.unwrap();
|
|
joiner.join_network(&name, &secret).await.unwrap();
|
|
|
|
wait_for_peers(&joiner, network_id, 1).await;
|
|
wait_for_peers(&listener, network_id, 1).await;
|
|
|
|
let status = joiner.network_status(network_id).await.unwrap();
|
|
assert_eq!(
|
|
status
|
|
.candidates
|
|
.iter()
|
|
.find(|candidate| candidate.endpoint_id == listener.endpoint_id())
|
|
.map(|candidate| candidate.source),
|
|
Some(CandidateSource::Bootstrap)
|
|
);
|
|
|
|
joiner.shutdown().await;
|
|
listener.shutdown().await;
|
|
drop(joiner);
|
|
drop(listener);
|
|
drop(joiner_dir);
|
|
drop(listener_dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_composite_backend_merges_its_sources() {
|
|
let (name, secret) = network("composite");
|
|
let keys = NetworkKeys::derive(&name, &secret);
|
|
|
|
let shared = SharedMemoryDiscovery::new();
|
|
let via_shared = TestAgent::spawn(&shared).await.unwrap();
|
|
let network_id = via_shared.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
let bootstrap_dir = tempfile::TempDir::new().unwrap();
|
|
let via_bootstrap = Agent::spawn(local_config(bootstrap_dir.path()))
|
|
.await
|
|
.unwrap();
|
|
via_bootstrap.join_network(&name, &secret).await.unwrap();
|
|
|
|
// One backend knows the bootstrap peer, the other knows the shared-table
|
|
// peer. Composed, the joiner reaches both.
|
|
let composite = Arc::new(CompositeDiscovery::new([
|
|
Arc::new(StaticBootstrap::new([via_bootstrap.local_addr()])) as Arc<dyn NetworkDiscovery>,
|
|
Arc::new(shared.clone()) as Arc<dyn NetworkDiscovery>,
|
|
]));
|
|
let joiner_dir = tempfile::TempDir::new().unwrap();
|
|
let joiner = Agent::spawn(local_config(joiner_dir.path()).with_discovery(composite))
|
|
.await
|
|
.unwrap();
|
|
joiner.join_network(&name, &secret).await.unwrap();
|
|
|
|
wait_for_peers(&joiner, network_id, 2).await;
|
|
|
|
// Publishing went to the shared backend, so the other agent finds us too.
|
|
assert!(shared.len(&keys.discovery_key()) >= 2);
|
|
|
|
joiner.shutdown().await;
|
|
via_bootstrap.shutdown().await;
|
|
via_shared.agent.shutdown().await;
|
|
drop(joiner);
|
|
drop(via_bootstrap);
|
|
drop(joiner_dir);
|
|
drop(bootstrap_dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discovery_entries_are_withdrawn_when_a_network_stops() {
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
let (name, secret) = network("withdrawn");
|
|
let keys = NetworkKeys::derive(&name, &secret);
|
|
|
|
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
|
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
settle().await;
|
|
assert_eq!(discovery.len(&keys.discovery_key()), 1);
|
|
|
|
agent.agent.deactivate_network(network_id).await.unwrap();
|
|
assert!(discovery.is_empty(&keys.discovery_key()));
|
|
|
|
agent.agent.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn forgetting_a_network_removes_it_from_the_state_store() {
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
let (name, secret) = network("forgettable");
|
|
|
|
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
|
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
|
assert_eq!(agent.agent.list_networks().await.unwrap().len(), 1);
|
|
|
|
agent.agent.forget_network(network_id).await.unwrap();
|
|
assert!(agent.agent.list_networks().await.unwrap().is_empty());
|
|
assert!(!agent.agent.is_active(network_id).await);
|
|
|
|
let dir = agent.stop().await;
|
|
let restarted =
|
|
Agent::spawn(local_config(dir.path()).with_discovery(Arc::new(discovery.clone())))
|
|
.await
|
|
.unwrap();
|
|
assert!(restarted.list_networks().await.unwrap().is_empty());
|
|
assert_eq!(restarted.status().await.unwrap().networks.len(), 0);
|
|
|
|
restarted.shutdown().await;
|
|
drop(restarted);
|
|
drop(dir);
|
|
}
|