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,425 @@
|
||||
//! Scenarios 3 and 9: an attacker without the secret, and the protocol's
|
||||
//! boundaries.
|
||||
//!
|
||||
//! These tests speak the wire protocol directly against a real agent, because
|
||||
//! that is the only way to present a *correct* public network id with a wrong
|
||||
//! proof, replay a captured proof on a second connection, or send a message
|
||||
//! before authenticating.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{TestAgent, network, settle, wait_event};
|
||||
use iroh::endpoint::{Connection, PortmapperConfig, RecvStream, SendStream, presets};
|
||||
use iroh::{Endpoint, EndpointAddr, RelayMode};
|
||||
use tsunagi::agent::Event;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::{NetworkId, NetworkKeys, NetworkName, NetworkSecret};
|
||||
use tsunagi::proto::handshake::{ROLE_INITIATOR, ROLE_RESPONDER};
|
||||
use tsunagi::proto::message::{
|
||||
ALPN, AuthProof, ControlMessage, Envelope, Hello, HelloAck, PROTOCOL_VERSION, decode, encode,
|
||||
};
|
||||
use tsunagi::proto::{read_frame, write_frame};
|
||||
use tsunagi::test_support;
|
||||
|
||||
/// A bare iroh endpoint with no tsunagi agent behind it.
|
||||
async fn raw_endpoint() -> Endpoint {
|
||||
Endpoint::builder(presets::Minimal)
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.relay_mode(RelayMode::Disabled)
|
||||
.clear_address_lookup()
|
||||
.portmapper_config(PortmapperConfig::Disabled)
|
||||
.clear_ip_transports()
|
||||
.bind_addr("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.bind()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn open_control_stream(
|
||||
endpoint: &Endpoint,
|
||||
target: EndpointAddr,
|
||||
) -> (Connection, SendStream, RecvStream) {
|
||||
let conn = endpoint.connect(target, ALPN).await.unwrap();
|
||||
let (send, recv) = conn.open_bi().await.unwrap();
|
||||
(conn, send, recv)
|
||||
}
|
||||
|
||||
const LIMIT: usize = 64 * 1024;
|
||||
|
||||
/// Sends `Hello` and reads the responder's `HelloAck`.
|
||||
async fn exchange_hellos(
|
||||
send: &mut SendStream,
|
||||
recv: &mut RecvStream,
|
||||
version: u16,
|
||||
network_id: NetworkId,
|
||||
nonce: [u8; 16],
|
||||
) -> HelloAck {
|
||||
let hello = Hello {
|
||||
version,
|
||||
network_id: *network_id.as_bytes(),
|
||||
nonce,
|
||||
};
|
||||
write_frame(send, &encode(&hello).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
decode(&read_frame(recv, LIMIT).await.unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_attacker_who_knows_the_public_network_id_is_still_rejected() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("closed-network");
|
||||
|
||||
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = victim.agent.subscribe();
|
||||
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// The attacker knows the address and the correct *public* network id. It
|
||||
// does not know the secret, so it cannot compute a valid proof.
|
||||
let attacker = raw_endpoint().await;
|
||||
let (conn, mut send, mut recv) =
|
||||
open_control_stream(&attacker, victim.agent.local_addr()).await;
|
||||
|
||||
let _ack = exchange_hellos(
|
||||
&mut send,
|
||||
&mut recv,
|
||||
PROTOCOL_VERSION,
|
||||
network_id,
|
||||
[9u8; 16],
|
||||
)
|
||||
.await;
|
||||
let bogus = AuthProof { proof: [0xAB; 32] };
|
||||
write_frame(&mut send, &encode(&bogus).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The responder must not reveal a proof of its own and must reject us.
|
||||
let reply = read_frame(&mut recv, LIMIT).await;
|
||||
assert!(
|
||||
reply.is_err(),
|
||||
"the agent must not answer an invalid proof, got {reply:?}"
|
||||
);
|
||||
|
||||
let reason = wait_event(&mut events, |event| match event {
|
||||
Event::HandshakeRejected { reason, .. } => Some(reason.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
reason.contains("authentication failed"),
|
||||
"unexpected reason: {reason}"
|
||||
);
|
||||
|
||||
// The victim is unharmed: no session, and the network is still running.
|
||||
let status = victim.agent.network_status(network_id).await.unwrap();
|
||||
assert!(status.peers.is_empty());
|
||||
assert_eq!(status.metrics.sessions_established, 0);
|
||||
|
||||
conn.close(0u32.into(), b"done");
|
||||
attacker.close().await;
|
||||
victim.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_wrong_secret_under_the_same_name_lands_in_a_different_space() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let name = NetworkName::new("same-name").unwrap();
|
||||
|
||||
let good = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let bad = TestAgent::spawn(&discovery).await.unwrap();
|
||||
|
||||
let good_id = good
|
||||
.agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
let bad_id = bad
|
||||
.agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(good_id, bad_id);
|
||||
|
||||
// Even with discovery wired together, the two never form a session.
|
||||
//
|
||||
// This test only shows that discovery separated them; the authoritative
|
||||
// check that the secret itself gates membership is
|
||||
// `an_attacker_who_knows_the_public_network_id_is_still_rejected`.
|
||||
settle().await;
|
||||
let status = good.agent.network_status(good_id).await.unwrap();
|
||||
assert!(status.peers.is_empty());
|
||||
|
||||
good.agent.shutdown().await;
|
||||
bad.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unsupported_protocol_version_is_rejected() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("versioned");
|
||||
|
||||
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = victim.agent.subscribe();
|
||||
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let attacker = raw_endpoint().await;
|
||||
let (conn, mut send, mut recv) =
|
||||
open_control_stream(&attacker, victim.agent.local_addr()).await;
|
||||
|
||||
let hello = Hello {
|
||||
version: PROTOCOL_VERSION + 7,
|
||||
network_id: *network_id.as_bytes(),
|
||||
nonce: [1u8; 16],
|
||||
};
|
||||
write_frame(&mut send, &encode(&hello).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(read_frame(&mut recv, LIMIT).await.is_err());
|
||||
|
||||
let reason = wait_event(&mut events, |event| match event {
|
||||
Event::HandshakeRejected { reason, .. } => Some(reason.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(reason.contains("version"), "unexpected reason: {reason}");
|
||||
|
||||
// The agent is still alive and usable afterwards.
|
||||
assert!(victim.agent.network_status(network_id).await.is_ok());
|
||||
|
||||
conn.close(0u32.into(), b"done");
|
||||
attacker.close().await;
|
||||
victim.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_control_message_before_authentication_is_rejected() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("no-early-messages");
|
||||
|
||||
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = victim.agent.subscribe();
|
||||
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let attacker = raw_endpoint().await;
|
||||
let (conn, mut send, mut recv) =
|
||||
open_control_stream(&attacker, victim.agent.local_addr()).await;
|
||||
|
||||
// A perfectly well formed control message, sent where a Hello belongs.
|
||||
let envelope = Envelope {
|
||||
network_id: *network_id.as_bytes(),
|
||||
message: ControlMessage::Ping {
|
||||
seq: 1,
|
||||
payload: b"too early".to_vec(),
|
||||
},
|
||||
};
|
||||
write_frame(&mut send, &encode(&envelope).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(read_frame(&mut recv, LIMIT).await.is_err());
|
||||
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::HandshakeRejected { .. } => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let status = victim.agent.network_status(network_id).await.unwrap();
|
||||
assert!(status.peers.is_empty());
|
||||
|
||||
conn.close(0u32.into(), b"done");
|
||||
attacker.close().await;
|
||||
victim.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_oversized_frame_is_rejected_before_it_is_allocated() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("bounded-frames");
|
||||
|
||||
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = victim.agent.subscribe();
|
||||
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let attacker = raw_endpoint().await;
|
||||
let (conn, mut send, mut recv) =
|
||||
open_control_stream(&attacker, victim.agent.local_addr()).await;
|
||||
|
||||
// Announce four gigabytes and then send nothing. The agent must reject the
|
||||
// header instead of allocating the buffer.
|
||||
send.write_all(&u32::MAX.to_be_bytes()).await.unwrap();
|
||||
assert!(read_frame(&mut recv, LIMIT).await.is_err());
|
||||
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::HandshakeRejected { reason, .. } if reason.contains("exceeds") => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Still serving other work.
|
||||
assert!(victim.agent.network_status(network_id).await.is_ok());
|
||||
|
||||
conn.close(0u32.into(), b"done");
|
||||
attacker.close().await;
|
||||
victim.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_proof_cannot_be_replayed_on_another_connection() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("channel-bound");
|
||||
|
||||
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = victim.agent.subscribe();
|
||||
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// A legitimate member: it knows the secret and can compute real proofs.
|
||||
let keys = NetworkKeys::derive(&name, &secret);
|
||||
let auth_key = test_support::auth_key(&keys);
|
||||
let member = raw_endpoint().await;
|
||||
let local = *member.id().as_bytes();
|
||||
let remote = *victim.agent.endpoint_id().as_bytes();
|
||||
let network_bytes = *network_id.as_bytes();
|
||||
|
||||
// Connection one: a genuine, successful handshake.
|
||||
let (conn1, mut send1, mut recv1) =
|
||||
open_control_stream(&member, victim.agent.local_addr()).await;
|
||||
let nonce_i = [11u8; 16];
|
||||
let ack1 = exchange_hellos(
|
||||
&mut send1,
|
||||
&mut recv1,
|
||||
PROTOCOL_VERSION,
|
||||
network_id,
|
||||
nonce_i,
|
||||
)
|
||||
.await;
|
||||
let cb1 = test_support::channel_binding(&conn1, &network_bytes).unwrap();
|
||||
let genuine = test_support::compute_proof(
|
||||
&auth_key,
|
||||
ROLE_INITIATOR,
|
||||
PROTOCOL_VERSION,
|
||||
&network_bytes,
|
||||
&local,
|
||||
&remote,
|
||||
&cb1,
|
||||
&nonce_i,
|
||||
&ack1.nonce,
|
||||
);
|
||||
write_frame(
|
||||
&mut send1,
|
||||
&encode(&AuthProof { proof: genuine }).unwrap(),
|
||||
LIMIT,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let their_proof: AuthProof = decode(&read_frame(&mut recv1, LIMIT).await.unwrap()).unwrap();
|
||||
|
||||
// The responder proved membership too, and it used the responder role.
|
||||
let expected = test_support::compute_proof(
|
||||
&auth_key,
|
||||
ROLE_RESPONDER,
|
||||
PROTOCOL_VERSION,
|
||||
&network_bytes,
|
||||
&local,
|
||||
&remote,
|
||||
&cb1,
|
||||
&nonce_i,
|
||||
&ack1.nonce,
|
||||
);
|
||||
assert_eq!(their_proof.proof, expected, "responder proof must verify");
|
||||
assert_ne!(
|
||||
their_proof.proof, genuine,
|
||||
"roles must not produce the same proof, or it could be reflected"
|
||||
);
|
||||
|
||||
// Connection two: replay the captured proof verbatim. The TLS exporter
|
||||
// differs per connection, so the proof no longer matches.
|
||||
let (conn2, mut send2, mut recv2) =
|
||||
open_control_stream(&member, victim.agent.local_addr()).await;
|
||||
let ack2 = exchange_hellos(
|
||||
&mut send2,
|
||||
&mut recv2,
|
||||
PROTOCOL_VERSION,
|
||||
network_id,
|
||||
nonce_i,
|
||||
)
|
||||
.await;
|
||||
let cb2 = test_support::channel_binding(&conn2, &network_bytes).unwrap();
|
||||
assert_ne!(cb1, cb2, "channel binding must differ between connections");
|
||||
let _ = ack2;
|
||||
|
||||
write_frame(
|
||||
&mut send2,
|
||||
&encode(&AuthProof { proof: genuine }).unwrap(),
|
||||
LIMIT,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
read_frame(&mut recv2, LIMIT).await.is_err(),
|
||||
"a replayed proof must be rejected"
|
||||
);
|
||||
|
||||
let reason = wait_event(&mut events, |event| match event {
|
||||
Event::HandshakeRejected { reason, .. } if reason.contains("authentication failed") => {
|
||||
Some(reason.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(reason.contains("authentication failed"));
|
||||
|
||||
conn1.close(0u32.into(), b"done");
|
||||
conn2.close(0u32.into(), b"done");
|
||||
member.close().await;
|
||||
victim.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_hello_for_an_inactive_network_is_rejected() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("active-only");
|
||||
|
||||
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = victim.agent.subscribe();
|
||||
victim.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// A different, perfectly valid network space the victim is not in.
|
||||
let other = NetworkKeys::derive(
|
||||
&NetworkName::new("somewhere-else").unwrap(),
|
||||
&NetworkSecret::generate(),
|
||||
);
|
||||
|
||||
let attacker = raw_endpoint().await;
|
||||
let (conn, mut send, mut recv) =
|
||||
open_control_stream(&attacker, victim.agent.local_addr()).await;
|
||||
let hello = Hello {
|
||||
version: PROTOCOL_VERSION,
|
||||
network_id: *other.network_id().as_bytes(),
|
||||
nonce: [3u8; 16],
|
||||
};
|
||||
write_frame(&mut send, &encode(&hello).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(read_frame(&mut recv, LIMIT).await.is_err());
|
||||
|
||||
let reason = wait_event(&mut events, |event| match event {
|
||||
Event::HandshakeRejected {
|
||||
network, reason, ..
|
||||
} => {
|
||||
// Before a successful handshake the claimed network is unverified,
|
||||
// so it must not be reported as fact.
|
||||
assert!(network.is_none());
|
||||
Some(reason.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(reason.contains("unknown"), "unexpected reason: {reason}");
|
||||
|
||||
conn.close(0u32.into(), b"done");
|
||||
attacker.close().await;
|
||||
victim.agent.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Scenario 7: the disposable cache and the mandatory state store.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use common::{TestAgent, config_with, local_config, network, wait_for_peers};
|
||||
use tsunagi::config::StoragePaths;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::storage::CacheOutcome;
|
||||
use tsunagi::{Agent, Error};
|
||||
|
||||
/// Overwrites a file with bytes that are definitely not a SQLite database.
|
||||
fn corrupt(path: &std::path::Path) {
|
||||
let mut file = std::fs::File::create(path).unwrap();
|
||||
file.write_all(&[0x7f; 8192]).unwrap();
|
||||
file.sync_all().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_missing_cache_is_recreated_and_does_not_block_connecting() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("missing-cache");
|
||||
|
||||
let peer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let subject = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = peer.agent.join_network(&name, &secret).await.unwrap();
|
||||
subject.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&peer.agent, network_id, 1).await;
|
||||
|
||||
let dir = subject.stop().await;
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
std::fs::remove_dir_all(&paths.cache_dir).unwrap();
|
||||
assert!(!paths.cache_db().exists());
|
||||
|
||||
let restarted = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restarted.status().await.unwrap().cache_outcome,
|
||||
CacheOutcome::Created
|
||||
);
|
||||
wait_for_peers(&restarted, network_id, 1).await;
|
||||
|
||||
restarted.shutdown().await;
|
||||
peer.agent.shutdown().await;
|
||||
drop(restarted);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_corrupt_cache_is_discarded_and_does_not_block_connecting() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("corrupt-cache");
|
||||
|
||||
let peer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let subject = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = peer.agent.join_network(&name, &secret).await.unwrap();
|
||||
subject.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&peer.agent, network_id, 1).await;
|
||||
|
||||
let device_id = subject.agent.endpoint_id();
|
||||
let dir = subject.stop().await;
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
corrupt(&paths.cache_db());
|
||||
|
||||
let restarted = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let status = restarted.status().await.unwrap();
|
||||
assert!(
|
||||
matches!(status.cache_outcome, CacheOutcome::Reset(_)),
|
||||
"expected the cache to be discarded, got {:?}",
|
||||
status.cache_outcome
|
||||
);
|
||||
assert!(status.cache_healthy);
|
||||
assert_eq!(
|
||||
restarted.endpoint_id(),
|
||||
device_id,
|
||||
"a bad cache must not touch the identity"
|
||||
);
|
||||
wait_for_peers(&restarted, network_id, 1).await;
|
||||
|
||||
restarted.shutdown().await;
|
||||
peer.agent.shutdown().await;
|
||||
drop(restarted);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_stale_cache_does_not_prevent_connecting_through_discovery() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("stale-cache");
|
||||
|
||||
let peer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let subject = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = peer.agent.join_network(&name, &secret).await.unwrap();
|
||||
subject.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&peer.agent, network_id, 1).await;
|
||||
|
||||
// Stop both. When they come back they bind new ports, so every cached
|
||||
// address hint is stale, and only fresh discovery can bridge the gap.
|
||||
let peer_dir = peer.stop().await;
|
||||
let subject_dir = subject.stop().await;
|
||||
assert!(
|
||||
StoragePaths::under(subject_dir.path()).cache_db().exists(),
|
||||
"hints were written, so the cache is genuinely stale now"
|
||||
);
|
||||
|
||||
let peer_again = Agent::spawn(config_with(peer_dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let subject_again = Agent::spawn(config_with(subject_dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_peers(&subject_again, network_id, 1).await;
|
||||
wait_for_peers(&peer_again, network_id, 1).await;
|
||||
|
||||
peer_again.shutdown().await;
|
||||
subject_again.shutdown().await;
|
||||
drop(peer_again);
|
||||
drop(subject_again);
|
||||
drop(peer_dir);
|
||||
drop(subject_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_corrupt_state_store_is_an_error_and_never_a_fresh_identity() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("corrupt-state");
|
||||
|
||||
let subject = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let device_id = subject.agent.endpoint_id();
|
||||
subject.agent.join_network(&name, &secret).await.unwrap();
|
||||
let dir = subject.stop().await;
|
||||
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
corrupt(&paths.state_db());
|
||||
|
||||
let result = Agent::spawn(config_with(dir.path(), &discovery)).await;
|
||||
match result {
|
||||
Err(Error::StateCorrupted { path, reason }) => {
|
||||
assert_eq!(path, paths.state_db());
|
||||
assert!(!reason.is_empty());
|
||||
}
|
||||
Err(other) => panic!("expected StateCorrupted, got {other:?}"),
|
||||
Ok(agent) => {
|
||||
let new_id = agent.endpoint_id();
|
||||
agent.shutdown().await;
|
||||
panic!(
|
||||
"a corrupt state store must not yield a working agent (id {new_id}) — the previous identity was {device_id}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_state_store_from_a_newer_build_is_refused() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
std::fs::create_dir_all(&paths.state_dir).unwrap();
|
||||
|
||||
{
|
||||
let conn = rusqlite::Connection::open(paths.state_db()).unwrap();
|
||||
conn.pragma_update(None, "user_version", 999i64).unwrap();
|
||||
}
|
||||
|
||||
let result = Agent::spawn(local_config(dir.path())).await;
|
||||
assert!(
|
||||
matches!(result, Err(Error::UnsupportedSchema { found: 999, .. })),
|
||||
"expected UnsupportedSchema"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secrets_never_appear_in_status_or_debug_output() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("no-leaks");
|
||||
|
||||
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let status = agent.agent.status().await.unwrap();
|
||||
let rendered = format!("{status:?}");
|
||||
let encoded = secret.encode();
|
||||
assert!(!rendered.contains(encoded.as_str()));
|
||||
assert!(rendered.contains(&network_id.to_string()) || rendered.contains("NetworkId"));
|
||||
|
||||
let network_status = agent.agent.network_status(network_id).await.unwrap();
|
||||
assert!(!format!("{network_status:?}").contains(encoded.as_str()));
|
||||
|
||||
let keys = tsunagi::identity::NetworkKeys::derive(&name, &secret);
|
||||
let keys_debug = format!("{keys:?}");
|
||||
assert!(keys_debug.contains("<redacted>"));
|
||||
assert!(!keys_debug.contains(encoded.as_str()));
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Shared helpers for the integration tests.
|
||||
//!
|
||||
//! Every test uses real iroh endpoints on loopback, its own temporary SQLite
|
||||
//! files and independent agent instances. Only discovery is substituted; iroh,
|
||||
//! the handshake, message passing and persistent storage are not.
|
||||
//!
|
||||
//! Synchronisation is always "wait for a specific event or condition, under one
|
||||
//! overall deadline", never a fixed multi-second sleep.
|
||||
|
||||
#![allow(dead_code, clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tempfile::TempDir;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tsunagi::agent::Event;
|
||||
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::{NetworkName, NetworkSecret};
|
||||
use tsunagi::{Agent, Result};
|
||||
|
||||
/// Installs a tracing subscriber when `TSUNAGI_TEST_LOG` is set.
|
||||
///
|
||||
/// The library never installs a global subscriber itself; tests opt in.
|
||||
pub fn init_tracing() {
|
||||
use std::sync::Once;
|
||||
static ONCE: Once = Once::new();
|
||||
ONCE.call_once(|| {
|
||||
if let Ok(filter) = std::env::var("TSUNAGI_TEST_LOG") {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::new(filter))
|
||||
.with_writer(std::io::stderr)
|
||||
.try_init();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Overall deadline for anything a test waits on.
|
||||
pub const DEADLINE: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How often condition polling re-checks. Never used as the primary sync.
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||
|
||||
/// Builds a fully local configuration rooted at `dir`.
|
||||
///
|
||||
/// Loopback binding with a dynamic port, no relay, no address lookup and no
|
||||
/// port mapping, so the suite needs neither the internet nor privileges.
|
||||
/// Timeouts are shortened so that failure paths finish quickly.
|
||||
pub fn local_config(dir: &Path) -> AgentConfig {
|
||||
let limits = tsunagi::Limits {
|
||||
dial_timeout: Duration::from_millis(1500),
|
||||
handshake_timeout: Duration::from_secs(5),
|
||||
..Default::default()
|
||||
};
|
||||
AgentConfig::new(StoragePaths::under(dir))
|
||||
.with_transport(TransportPolicy::LocalOnly)
|
||||
.with_loopback_bind()
|
||||
.with_discovery_interval(Duration::from_millis(150))
|
||||
.with_limits(limits)
|
||||
}
|
||||
|
||||
/// Builds a local configuration wired to a shared in-memory discovery table.
|
||||
pub fn config_with(dir: &Path, discovery: &SharedMemoryDiscovery) -> AgentConfig {
|
||||
local_config(dir).with_discovery(Arc::new(discovery.clone()))
|
||||
}
|
||||
|
||||
/// A temporary directory plus the agent running on it.
|
||||
pub struct TestAgent {
|
||||
pub dir: TempDir,
|
||||
pub agent: Agent,
|
||||
}
|
||||
|
||||
impl TestAgent {
|
||||
/// Starts an agent on a fresh temporary directory.
|
||||
pub async fn spawn(discovery: &SharedMemoryDiscovery) -> Result<Self> {
|
||||
init_tracing();
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let agent = Agent::spawn(config_with(dir.path(), discovery)).await?;
|
||||
Ok(Self { dir, agent })
|
||||
}
|
||||
|
||||
/// Starts an agent with a caller-supplied configuration on a fresh dir.
|
||||
pub async fn spawn_with(
|
||||
build: impl FnOnce(AgentConfig) -> AgentConfig,
|
||||
discovery: &SharedMemoryDiscovery,
|
||||
) -> Result<Self> {
|
||||
init_tracing();
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let agent = Agent::spawn(build(config_with(dir.path(), discovery))).await?;
|
||||
Ok(Self { dir, agent })
|
||||
}
|
||||
|
||||
/// Stops the agent and returns the directory so it can be reopened.
|
||||
pub async fn stop(self) -> TempDir {
|
||||
self.agent.shutdown().await;
|
||||
self.dir
|
||||
}
|
||||
}
|
||||
|
||||
/// A network name and a fresh high-entropy secret.
|
||||
pub fn network(name: &str) -> (NetworkName, NetworkSecret) {
|
||||
(
|
||||
NetworkName::new(name).expect("valid network name"),
|
||||
NetworkSecret::generate(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Waits for an event matching `predicate`, under the global deadline.
|
||||
pub async fn wait_event<T>(
|
||||
rx: &mut tokio::sync::broadcast::Receiver<Event>,
|
||||
predicate: impl Fn(&Event) -> Option<T>,
|
||||
) -> T {
|
||||
let deadline = Instant::now() + DEADLINE;
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
loop {
|
||||
let remaining = deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.unwrap_or_default();
|
||||
assert!(!remaining.is_zero(), "timed out waiting for an event");
|
||||
|
||||
match tokio::time::timeout(remaining, rx.recv()).await {
|
||||
Ok(Ok(event)) => {
|
||||
if let Some(value) = predicate(&event) {
|
||||
return value;
|
||||
}
|
||||
if seen.len() < 12 {
|
||||
let label = format!("{event:?}");
|
||||
seen.push(label.chars().take(70).collect());
|
||||
}
|
||||
}
|
||||
Ok(Err(RecvError::Lagged(skipped))) => {
|
||||
panic!("event subscriber lagged, missed {skipped} events; first seen: {seen:#?}");
|
||||
}
|
||||
Ok(Err(RecvError::Closed)) => panic!("event channel closed while waiting"),
|
||||
Err(_) => panic!("timed out waiting for an event"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls an async condition until it returns `Some`, under the global deadline.
|
||||
pub async fn wait_until<T, F, Fut>(what: &str, mut probe: F) -> T
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Option<T>>,
|
||||
{
|
||||
let deadline = Instant::now() + DEADLINE;
|
||||
loop {
|
||||
if let Some(value) = probe().await {
|
||||
return value;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"timed out waiting for condition: {what}"
|
||||
);
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets a few discovery rounds pass.
|
||||
///
|
||||
/// Only ever used before asserting that something did **not** happen; waiting
|
||||
/// for success always goes through [`wait_event`] or [`wait_until`].
|
||||
pub async fn settle() {
|
||||
tokio::time::sleep(Duration::from_millis(900)).await;
|
||||
}
|
||||
|
||||
/// Waits until `agent` has `count` authenticated peers in `network`.
|
||||
pub async fn wait_for_peers(
|
||||
agent: &Agent,
|
||||
network: tsunagi::NetworkId,
|
||||
count: usize,
|
||||
) -> Vec<iroh::EndpointId> {
|
||||
wait_until(&format!("{count} peers in {network}"), || async move {
|
||||
let status = agent.network_status(network).await.ok()?;
|
||||
if status.peers.len() >= count {
|
||||
Some(status.connected_peers())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! The full vertical slice: persistent identity, network space, discovery,
|
||||
//! real iroh connections, authentication and a control message exchange.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{TestAgent, network, wait_event, wait_for_peers};
|
||||
use tsunagi::agent::Event;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::proto::ControlMessage;
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_agents_authenticate_and_exchange_messages() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("vertical-slice");
|
||||
|
||||
let a = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let b = TestAgent::spawn(&discovery).await.unwrap();
|
||||
|
||||
let mut events_a = a.agent.subscribe();
|
||||
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
let same_id = b.agent.join_network(&name, &secret).await.unwrap();
|
||||
assert_eq!(
|
||||
network_id, same_id,
|
||||
"the same name and secret must produce the same network space"
|
||||
);
|
||||
|
||||
let peers = wait_for_peers(&a.agent, network_id, 1).await;
|
||||
assert_eq!(peers, vec![b.agent.endpoint_id()]);
|
||||
|
||||
// The exchange itself: a ping must come back as a matching pong.
|
||||
a.agent
|
||||
.send(
|
||||
network_id,
|
||||
b.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: 42,
|
||||
payload: b"vertical".to_vec(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let payload = wait_event(&mut events_a, |event| match event {
|
||||
Event::MessageReceived {
|
||||
network,
|
||||
peer,
|
||||
message: ControlMessage::Pong { seq: 42, payload },
|
||||
} if *network == network_id && *peer == b.agent.endpoint_id() => Some(payload.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(payload, b"vertical".to_vec());
|
||||
|
||||
// Both sides announce a hostname over the authenticated session.
|
||||
let status = a.agent.network_status(network_id).await.unwrap();
|
||||
let peer = &status.peers[0];
|
||||
assert_eq!(peer.hostname.as_deref(), Some(b.agent.hostname()));
|
||||
assert!(peer.transport != tsunagi::net::TransportKind::Unknown);
|
||||
assert!(peer.rtt.is_some(), "a verified path must report an RTT");
|
||||
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Scenario 1: deterministic network identity.
|
||||
//!
|
||||
//! The same name and secret must yield the same network space on different
|
||||
//! devices, and nothing else — hostname, device key, restart — may change it.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{TestAgent, config_with, network};
|
||||
use tsunagi::Agent;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
|
||||
#[test]
|
||||
fn derivation_is_a_pure_function_of_name_and_secret() {
|
||||
let name = NetworkName::new("home").unwrap();
|
||||
let other = NetworkName::new("Home").unwrap();
|
||||
let secret = NetworkSecret::generate();
|
||||
let other_secret = NetworkSecret::generate();
|
||||
|
||||
let a = NetworkKeys::derive(&name, &secret);
|
||||
let b = NetworkKeys::derive(&name, &secret);
|
||||
assert_eq!(a.network_id(), b.network_id());
|
||||
assert_eq!(a.discovery_key(), b.discovery_key());
|
||||
assert_eq!(a.descriptor(), b.descriptor());
|
||||
|
||||
// A different name is a different space. Names are used verbatim, so case
|
||||
// matters.
|
||||
assert_ne!(
|
||||
a.network_id(),
|
||||
NetworkKeys::derive(&other, &secret).network_id()
|
||||
);
|
||||
// A different secret is a different space.
|
||||
assert_ne!(
|
||||
a.network_id(),
|
||||
NetworkKeys::derive(&name, &other_secret).network_id()
|
||||
);
|
||||
// Separated key material: the discovery key is not the network id.
|
||||
assert_ne!(a.network_id().as_bytes(), a.discovery_key().as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_carries_no_creator_time_or_secret() {
|
||||
let name = NetworkName::new("shared").unwrap();
|
||||
let secret = NetworkSecret::generate();
|
||||
let first = NetworkKeys::derive(&name, &secret).descriptor();
|
||||
let second = NetworkKeys::derive(&name, &secret).descriptor();
|
||||
|
||||
// Two independently built descriptors are byte identical: no random
|
||||
// creator id, no creation timestamp, no owner signature.
|
||||
assert_eq!(first.to_canonical_bytes(), second.to_canonical_bytes());
|
||||
|
||||
let encoded = first.to_canonical_bytes();
|
||||
let secret_bytes = secret.encode();
|
||||
assert!(
|
||||
!encoded
|
||||
.windows(secret_bytes.len())
|
||||
.any(|window| window == secret_bytes.as_bytes()),
|
||||
"the secret must never appear in the public descriptor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_validated_not_silently_normalised() {
|
||||
assert!(NetworkName::new("").is_err());
|
||||
assert!(NetworkName::new(" home").is_err(), "must not be trimmed");
|
||||
assert!(NetworkName::new("home ").is_err(), "must not be trimmed");
|
||||
assert!(NetworkName::new("ho\nme").is_err());
|
||||
assert!(NetworkName::new("a".repeat(65)).is_err());
|
||||
assert_eq!(NetworkName::new("home").unwrap().as_str(), "home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_are_not_truncated_or_normalised() {
|
||||
let secret = NetworkSecret::generate();
|
||||
let text = secret.encode();
|
||||
let round_tripped = NetworkSecret::decode(&text).unwrap();
|
||||
assert_eq!(secret, round_tripped);
|
||||
|
||||
// Short secrets are rejected rather than stretched.
|
||||
assert!(NetworkSecret::from_bytes(vec![7u8; 15]).is_err());
|
||||
assert!(NetworkSecret::from_bytes(vec![7u8; 16]).is_ok());
|
||||
|
||||
// Debug output must not leak the secret.
|
||||
let rendered = format!("{secret:?}");
|
||||
assert_eq!(rendered, "NetworkSecret(<redacted>)");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_devices_and_hostnames_agree_on_the_network_id() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("agreement");
|
||||
|
||||
let a = TestAgent::spawn_with(|cfg| cfg.with_hostname("alpha"), &discovery)
|
||||
.await
|
||||
.unwrap();
|
||||
let b = TestAgent::spawn_with(|cfg| cfg.with_hostname("beta"), &discovery)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(
|
||||
a.agent.endpoint_id(),
|
||||
b.agent.endpoint_id(),
|
||||
"different devices must have different endpoint ids"
|
||||
);
|
||||
assert_eq!(a.agent.hostname(), "alpha");
|
||||
assert_eq!(b.agent.hostname(), "beta");
|
||||
|
||||
let id_a = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
let id_b = b.agent.join_network(&name, &secret).await.unwrap();
|
||||
assert_eq!(id_a, id_b);
|
||||
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_keeps_the_device_and_network_identity() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("stable");
|
||||
|
||||
let first = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let device_id = first.agent.endpoint_id();
|
||||
let network_id = first.agent.join_network(&name, &secret).await.unwrap();
|
||||
let dir = first.stop().await;
|
||||
|
||||
let reopened = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
reopened.endpoint_id(),
|
||||
device_id,
|
||||
"restarting must not mint a new peer"
|
||||
);
|
||||
let networks = reopened.list_networks().await.unwrap();
|
||||
assert_eq!(networks.len(), 1);
|
||||
assert_eq!(networks[0].network_id, network_id);
|
||||
assert!(networks[0].active, "auto-start networks come back up");
|
||||
|
||||
reopened.shutdown().await;
|
||||
drop(reopened);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changing_the_secret_keeps_the_device_identity() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let device_id = agent.endpoint_id();
|
||||
|
||||
let name = NetworkName::new("rotating").unwrap();
|
||||
let old = agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
let new = agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(old, new, "a new secret is a new network space");
|
||||
assert_eq!(
|
||||
agent.endpoint_id(),
|
||||
device_id,
|
||||
"rotating the network secret must not change the persistent iroh id"
|
||||
);
|
||||
|
||||
agent.shutdown().await;
|
||||
drop(agent);
|
||||
drop(dir);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Scenario 2: several agents find each other, authenticate for real and
|
||||
//! exchange distinguishable messages.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::{TestAgent, network, wait_event, wait_for_peers, wait_until};
|
||||
use tsunagi::agent::Event;
|
||||
use tsunagi::dataplane::TestCapabilityPlugin;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::proto::ControlMessage;
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_agents_form_a_mesh_and_exchange_distinguishable_messages() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("mesh-of-four");
|
||||
|
||||
let mut agents = Vec::new();
|
||||
for index in 0..4 {
|
||||
let hostname = format!("host-{index}");
|
||||
agents.push(
|
||||
TestAgent::spawn_with(move |cfg| cfg.with_hostname(hostname), &discovery)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut network_ids = HashSet::new();
|
||||
for agent in &agents {
|
||||
network_ids.insert(agent.agent.join_network(&name, &secret).await.unwrap());
|
||||
}
|
||||
assert_eq!(network_ids.len(), 1, "one deterministic network space");
|
||||
let network_id = network_ids.into_iter().next().unwrap();
|
||||
|
||||
// Full mesh: every agent must end up with the other three.
|
||||
for agent in &agents {
|
||||
wait_for_peers(&agent.agent, network_id, 3).await;
|
||||
}
|
||||
|
||||
// Each peer announced its own hostname, so sessions are distinguishable.
|
||||
let status = agents[0].agent.network_status(network_id).await.unwrap();
|
||||
let hostnames: HashSet<String> = status
|
||||
.peers
|
||||
.iter()
|
||||
.filter_map(|peer| peer.hostname.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
hostnames.len(),
|
||||
3,
|
||||
"three distinct hostnames: {hostnames:?}"
|
||||
);
|
||||
|
||||
// Distinguishable request/response: each peer echoes its own sequence.
|
||||
let mut events = agents[0].agent.subscribe();
|
||||
for (index, peer) in agents.iter().skip(1).enumerate() {
|
||||
agents[0]
|
||||
.agent
|
||||
.send(
|
||||
network_id,
|
||||
peer.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: index as u64 + 1,
|
||||
payload: format!("to-{index}").into_bytes(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
while seen.len() < 3 {
|
||||
let (peer, seq, payload) = wait_event(&mut events, |event| match event {
|
||||
Event::MessageReceived {
|
||||
network,
|
||||
peer,
|
||||
message: ControlMessage::Pong { seq, payload },
|
||||
} if *network == network_id => Some((*peer, *seq, payload.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(payload, format!("to-{}", seq - 1).into_bytes());
|
||||
seen.insert(peer);
|
||||
}
|
||||
assert_eq!(seen.len(), 3);
|
||||
|
||||
for agent in agents {
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_late_joiner_is_picked_up_by_the_existing_members() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("late-joiner");
|
||||
|
||||
let first = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = first.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// Nobody else is there yet. That is "nobody found so far", not proof that
|
||||
// the network is empty, and the agent is ready regardless.
|
||||
let status = first.agent.network_status(network_id).await.unwrap();
|
||||
assert!(status.peers.is_empty());
|
||||
|
||||
let second = TestAgent::spawn(&discovery).await.unwrap();
|
||||
second.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
wait_for_peers(&first.agent, network_id, 1).await;
|
||||
wait_for_peers(&second.agent, network_id, 1).await;
|
||||
|
||||
first.agent.shutdown().await;
|
||||
second.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn opaque_plugin_capabilities_cross_the_control_plane() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("capabilities");
|
||||
|
||||
// An explicitly test-only capability: nothing here advertises WireGuard.
|
||||
let plugin_a = Arc::new(TestCapabilityPlugin::new("test-ip", b"payload-a".to_vec()));
|
||||
let plugin_b = Arc::new(TestCapabilityPlugin::new("test-ip", b"payload-b".to_vec()));
|
||||
|
||||
let a = TestAgent::spawn_with(
|
||||
{
|
||||
let plugin = Arc::clone(&plugin_a);
|
||||
move |cfg| cfg.with_plugin(plugin)
|
||||
},
|
||||
&discovery,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let b = TestAgent::spawn_with(
|
||||
{
|
||||
let plugin = Arc::clone(&plugin_b);
|
||||
move |cfg| cfg.with_plugin(plugin)
|
||||
},
|
||||
&discovery,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&a.agent, network_id, 1).await;
|
||||
|
||||
let observed = wait_until("plugin a sees b's capability", || {
|
||||
let plugin = Arc::clone(&plugin_a);
|
||||
async move {
|
||||
let seen = plugin.observed();
|
||||
if seen.is_empty() { None } else { Some(seen) }
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let (seen_network, seen_peer, capability) = &observed[0];
|
||||
assert_eq!(*seen_network, network_id);
|
||||
assert_eq!(*seen_peer, b.agent.endpoint_id());
|
||||
assert_eq!(capability.protocol, "test-ip");
|
||||
assert_eq!(capability.data, b"payload-b".to_vec());
|
||||
assert!(capability.enabled);
|
||||
|
||||
// The core carried the payload without interpreting it.
|
||||
let status = a.agent.network_status(network_id).await.unwrap();
|
||||
assert_eq!(status.peers[0].capabilities[0].data, b"payload-b".to_vec());
|
||||
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Scenario 4: one agent in two networks at once, with no bleed between them.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{TestAgent, network, settle, wait_event, wait_for_peers};
|
||||
use iroh::endpoint::{PortmapperConfig, presets};
|
||||
use iroh::{Endpoint, RelayMode};
|
||||
use tsunagi::agent::Event;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::NetworkKeys;
|
||||
use tsunagi::proto::handshake::ROLE_INITIATOR;
|
||||
use tsunagi::proto::message::{
|
||||
ALPN, AuthProof, ControlMessage, Envelope, Hello, HelloAck, PROTOCOL_VERSION, decode, encode,
|
||||
};
|
||||
use tsunagi::proto::{read_frame, write_frame};
|
||||
use tsunagi::test_support;
|
||||
|
||||
const LIMIT: usize = 64 * 1024;
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_agent_in_two_networks_keeps_them_apart() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name_a, secret_a) = network("alpha-net");
|
||||
let (name_b, secret_b) = network("beta-net");
|
||||
|
||||
let hub = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let alpha_peer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let beta_peer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
|
||||
let alpha = hub.agent.join_network(&name_a, &secret_a).await.unwrap();
|
||||
let beta = hub.agent.join_network(&name_b, &secret_b).await.unwrap();
|
||||
assert_ne!(alpha, beta);
|
||||
|
||||
alpha_peer
|
||||
.agent
|
||||
.join_network(&name_a, &secret_a)
|
||||
.await
|
||||
.unwrap();
|
||||
beta_peer
|
||||
.agent
|
||||
.join_network(&name_b, &secret_b)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_peers(&hub.agent, alpha, 1).await;
|
||||
wait_for_peers(&hub.agent, beta, 1).await;
|
||||
|
||||
// Statuses do not mix: each network sees exactly its own peer.
|
||||
let status_alpha = hub.agent.network_status(alpha).await.unwrap();
|
||||
let status_beta = hub.agent.network_status(beta).await.unwrap();
|
||||
assert_eq!(
|
||||
status_alpha.connected_peers(),
|
||||
vec![alpha_peer.agent.endpoint_id()]
|
||||
);
|
||||
assert_eq!(
|
||||
status_beta.connected_peers(),
|
||||
vec![beta_peer.agent.endpoint_id()]
|
||||
);
|
||||
|
||||
// Addressing a peer of one network through the other is refused locally.
|
||||
let wrong = hub
|
||||
.agent
|
||||
.send(
|
||||
alpha,
|
||||
beta_peer.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: 1,
|
||||
payload: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(wrong, Err(tsunagi::Error::NoSuchPeer { .. })));
|
||||
|
||||
// Messages stay in their own network.
|
||||
let mut events = hub.agent.subscribe();
|
||||
hub.agent
|
||||
.broadcast(
|
||||
alpha,
|
||||
ControlMessage::Ping {
|
||||
seq: 7,
|
||||
payload: b"alpha-only".to_vec(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let from = wait_event(&mut events, |event| match event {
|
||||
Event::MessageReceived {
|
||||
network,
|
||||
peer,
|
||||
message: ControlMessage::Pong { seq: 7, payload },
|
||||
} if payload == b"alpha-only" => Some((*network, *peer)),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(from, (alpha, alpha_peer.agent.endpoint_id()));
|
||||
|
||||
let beta_status = hub.agent.network_status(beta).await.unwrap();
|
||||
assert_eq!(
|
||||
beta_status.metrics.control_messages_received,
|
||||
beta_status.peers[0].control_messages_received,
|
||||
"beta's counters are its own"
|
||||
);
|
||||
assert!(
|
||||
beta_status
|
||||
.peers
|
||||
.iter()
|
||||
.all(|peer| peer.endpoint_id != alpha_peer.agent.endpoint_id())
|
||||
);
|
||||
|
||||
// Deactivating one network must not disturb the other.
|
||||
hub.agent.deactivate_network(alpha).await.unwrap();
|
||||
assert!(hub.agent.network_status(alpha).await.is_err());
|
||||
wait_for_peers(&hub.agent, beta, 1).await;
|
||||
hub.agent
|
||||
.send(
|
||||
beta,
|
||||
beta_peer.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: 8,
|
||||
payload: b"still-here".to_vec(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::MessageReceived {
|
||||
network,
|
||||
message: ControlMessage::Pong { seq: 8, .. },
|
||||
..
|
||||
} if *network == beta => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
hub.agent.shutdown().await;
|
||||
alpha_peer.agent.shutdown().await;
|
||||
beta_peer.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_authenticated_session_cannot_speak_for_another_network() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name_a, secret_a) = network("session-scope-a");
|
||||
let (name_b, secret_b) = network("session-scope-b");
|
||||
|
||||
let hub = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = hub.agent.subscribe();
|
||||
let alpha = hub.agent.join_network(&name_a, &secret_a).await.unwrap();
|
||||
let beta = hub.agent.join_network(&name_b, &secret_b).await.unwrap();
|
||||
|
||||
// A genuine member of `alpha`, driven by hand so it can misbehave.
|
||||
let keys = NetworkKeys::derive(&name_a, &secret_a);
|
||||
let auth_key = test_support::auth_key(&keys);
|
||||
let member = Endpoint::builder(presets::Minimal)
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.relay_mode(RelayMode::Disabled)
|
||||
.clear_address_lookup()
|
||||
.portmapper_config(PortmapperConfig::Disabled)
|
||||
.clear_ip_transports()
|
||||
.bind_addr("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.bind()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let conn = member.connect(hub.agent.local_addr(), ALPN).await.unwrap();
|
||||
let (mut send, mut recv) = conn.open_bi().await.unwrap();
|
||||
|
||||
let alpha_bytes = *alpha.as_bytes();
|
||||
let nonce_i = [5u8; 16];
|
||||
let hello = Hello {
|
||||
version: PROTOCOL_VERSION,
|
||||
network_id: alpha_bytes,
|
||||
nonce: nonce_i,
|
||||
};
|
||||
write_frame(&mut send, &encode(&hello).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
let ack: HelloAck = decode(&read_frame(&mut recv, LIMIT).await.unwrap()).unwrap();
|
||||
|
||||
let cb = test_support::channel_binding(&conn, &alpha_bytes).unwrap();
|
||||
let proof = test_support::compute_proof(
|
||||
&auth_key,
|
||||
ROLE_INITIATOR,
|
||||
PROTOCOL_VERSION,
|
||||
&alpha_bytes,
|
||||
member.id().as_bytes(),
|
||||
hub.agent.endpoint_id().as_bytes(),
|
||||
&cb,
|
||||
&nonce_i,
|
||||
&ack.nonce,
|
||||
);
|
||||
write_frame(&mut send, &encode(&AuthProof { proof }).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
let _responder_proof: AuthProof = decode(&read_frame(&mut recv, LIMIT).await.unwrap()).unwrap();
|
||||
|
||||
// Authenticated for alpha. Now try to speak for beta on the same session.
|
||||
let smuggled = Envelope {
|
||||
network_id: *beta.as_bytes(),
|
||||
message: ControlMessage::Ping {
|
||||
seq: 99,
|
||||
payload: b"wrong network".to_vec(),
|
||||
},
|
||||
};
|
||||
write_frame(&mut send, &encode(&smuggled).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let reason = wait_event(&mut events, |event| match event {
|
||||
Event::ProtocolViolation {
|
||||
network, reason, ..
|
||||
} if *network == Some(alpha) => Some(reason.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
reason.contains("network id does not match"),
|
||||
"unexpected reason: {reason}"
|
||||
);
|
||||
|
||||
// Beta saw nothing at all, and both networks keep running.
|
||||
let beta_status = hub.agent.network_status(beta).await.unwrap();
|
||||
assert_eq!(beta_status.metrics.control_messages_received, 0);
|
||||
assert!(beta_status.peers.is_empty());
|
||||
assert!(hub.agent.network_status(alpha).await.is_ok());
|
||||
|
||||
conn.close(0u32.into(), b"done");
|
||||
member.close().await;
|
||||
hub.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deactivating_one_network_leaves_the_agent_and_others_running() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name_a, secret_a) = network("keep-a");
|
||||
let (name_b, secret_b) = network("keep-b");
|
||||
|
||||
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let alpha = agent.agent.join_network(&name_a, &secret_a).await.unwrap();
|
||||
let beta = agent.agent.join_network(&name_b, &secret_b).await.unwrap();
|
||||
|
||||
agent.agent.deactivate_network(alpha).await.unwrap();
|
||||
settle().await;
|
||||
|
||||
let status = agent.agent.status().await.unwrap();
|
||||
assert_eq!(status.networks.len(), 2, "both stay configured");
|
||||
assert_eq!(
|
||||
status.network(&alpha).map(|net| net.state),
|
||||
Some(tsunagi::agent::NetworkState::Inactive)
|
||||
);
|
||||
assert_eq!(
|
||||
status.network(&beta).map(|net| net.state),
|
||||
Some(tsunagi::agent::NetworkState::Active)
|
||||
);
|
||||
|
||||
// Deactivating twice is an error, not a crash.
|
||||
assert!(agent.agent.deactivate_network(alpha).await.is_err());
|
||||
// And it can be brought back.
|
||||
agent.agent.activate_network(alpha).await.unwrap();
|
||||
assert!(agent.agent.network_status(alpha).await.is_ok());
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Scenarios 8 and 10: unreachable participants, bounded retries, and the
|
||||
//! agent lifecycle including state directory ownership.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{TestAgent, config_with, local_config, network, settle, wait_event, wait_for_peers};
|
||||
use iroh::{EndpointAddr, SecretKey};
|
||||
use tsunagi::agent::Event;
|
||||
use tsunagi::config::ReconnectPolicy;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::NetworkKeys;
|
||||
use tsunagi::proto::ControlMessage;
|
||||
use tsunagi::{Agent, Error};
|
||||
|
||||
/// An endpoint id nobody is listening for, at an address nothing answers on.
|
||||
fn dead_candidate() -> EndpointAddr {
|
||||
let unreachable: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
||||
EndpointAddr::new(SecretKey::generate().public()).with_ip_addr(unreachable)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_dead_candidate_does_not_hold_up_the_reachable_ones() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("dead-candidate");
|
||||
let keys = NetworkKeys::derive(&name, &secret);
|
||||
|
||||
// Poison the rendezvous table before anybody real shows up.
|
||||
let dead = dead_candidate();
|
||||
discovery.insert_raw(keys.discovery_key(), dead.clone());
|
||||
|
||||
let a = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let b = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = a.agent.subscribe();
|
||||
|
||||
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// The reachable peer still connects.
|
||||
let peers = wait_for_peers(&a.agent, network_id, 1).await;
|
||||
assert_eq!(peers, vec![b.agent.endpoint_id()]);
|
||||
|
||||
// And the dead candidate is reported as a failure, not silently forgotten.
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::DialFailed { peer, .. } if *peer == dead.id => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let status = a.agent.network_status(network_id).await.unwrap();
|
||||
assert!(
|
||||
status
|
||||
.candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.endpoint_id == dead.id
|
||||
&& candidate.consecutive_failures > 0),
|
||||
"a failing candidate must stay visible as an unverified candidate"
|
||||
);
|
||||
assert!(
|
||||
status.peers.iter().all(|peer| peer.endpoint_id != dead.id),
|
||||
"a candidate must never be reported as a peer"
|
||||
);
|
||||
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_vanished_peer_is_retried_with_backoff_and_others_keep_working() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("vanishing-peer");
|
||||
|
||||
let watcher = TestAgent::spawn_with(
|
||||
|cfg| {
|
||||
cfg.with_reconnect(ReconnectPolicy {
|
||||
initial_delay: Duration::from_millis(50),
|
||||
max_delay: Duration::from_millis(200),
|
||||
factor: 1.5,
|
||||
jitter: 0.2,
|
||||
max_consecutive_failures: None,
|
||||
})
|
||||
},
|
||||
&discovery,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let stayer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let leaver = TestAgent::spawn(&discovery).await.unwrap();
|
||||
|
||||
let network_id = watcher.agent.join_network(&name, &secret).await.unwrap();
|
||||
stayer.agent.join_network(&name, &secret).await.unwrap();
|
||||
leaver.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&watcher.agent, network_id, 2).await;
|
||||
|
||||
let leaver_id = leaver.agent.endpoint_id();
|
||||
let mut events = watcher.agent.subscribe();
|
||||
leaver.agent.shutdown().await;
|
||||
drop(leaver);
|
||||
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::PeerDisconnected { peer, .. } if *peer == leaver_id => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// The other peer is untouched and still answers.
|
||||
watcher
|
||||
.agent
|
||||
.send(
|
||||
network_id,
|
||||
stayer.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: 3,
|
||||
payload: b"still here".to_vec(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::MessageReceived {
|
||||
peer,
|
||||
message: ControlMessage::Pong { seq: 3, .. },
|
||||
..
|
||||
} if *peer == stayer.agent.endpoint_id() => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// The watcher does retry the peer that went away.
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::DialFailed { peer, .. } if *peer == leaver_id => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Retries are bounded by the backoff rather than spinning.
|
||||
settle().await;
|
||||
let status = watcher.agent.network_status(network_id).await.unwrap();
|
||||
let failures = status
|
||||
.candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.endpoint_id == leaver_id)
|
||||
.map(|candidate| candidate.consecutive_failures)
|
||||
.unwrap_or(0);
|
||||
assert!(
|
||||
(1..=40).contains(&failures),
|
||||
"expected bounded backed-off retries, got {failures}"
|
||||
);
|
||||
assert_eq!(
|
||||
status.connected_peers(),
|
||||
vec![stayer.agent.endpoint_id()],
|
||||
"the surviving peer keeps its session"
|
||||
);
|
||||
|
||||
watcher.agent.shutdown().await;
|
||||
stayer.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retries_stop_when_the_network_is_deactivated() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("stop-retrying");
|
||||
let keys = NetworkKeys::derive(&name, &secret);
|
||||
discovery.insert_raw(keys.discovery_key(), dead_candidate());
|
||||
|
||||
let agent = TestAgent::spawn_with(
|
||||
|cfg| {
|
||||
cfg.with_discovery_interval(Duration::from_millis(80))
|
||||
.with_reconnect(ReconnectPolicy {
|
||||
initial_delay: Duration::from_millis(20),
|
||||
max_delay: Duration::from_millis(60),
|
||||
factor: 1.2,
|
||||
jitter: 0.1,
|
||||
max_consecutive_failures: None,
|
||||
})
|
||||
},
|
||||
&discovery,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut events = agent.agent.subscribe();
|
||||
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// Retries are definitely happening.
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::DialFailed { .. } => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
agent.agent.deactivate_network(network_id).await.unwrap();
|
||||
|
||||
// Drain whatever was already queued, then require silence.
|
||||
while events.try_recv().is_ok() {}
|
||||
settle().await;
|
||||
let mut stragglers = 0;
|
||||
while let Ok(event) = events.try_recv() {
|
||||
if matches!(event, Event::DialFailed { .. }) {
|
||||
stragglers += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
stragglers, 0,
|
||||
"a deactivated network must stop dialling entirely"
|
||||
);
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_second_agent_on_the_same_state_directory_is_refused() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let first = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let second = Agent::spawn(config_with(dir.path(), &discovery)).await;
|
||||
match second {
|
||||
Err(Error::StateLocked { path }) => {
|
||||
assert!(path.starts_with(dir.path()));
|
||||
}
|
||||
Err(other) => panic!("expected StateLocked, got {other:?}"),
|
||||
Ok(agent) => {
|
||||
agent.shutdown().await;
|
||||
panic!("two live agents must not share one state directory");
|
||||
}
|
||||
}
|
||||
|
||||
// After a clean stop the directory is immediately claimable again.
|
||||
let device_id = first.endpoint_id();
|
||||
first.shutdown().await;
|
||||
drop(first);
|
||||
|
||||
let third = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(third.endpoint_id(), device_id);
|
||||
third.shutdown().await;
|
||||
drop(third);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_releases_resources_and_rejects_further_work() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("clean-stop");
|
||||
|
||||
let a = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let b = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&a.agent, network_id, 1).await;
|
||||
|
||||
a.agent.shutdown().await;
|
||||
|
||||
// The endpoint is closed and the networks are gone.
|
||||
assert!(a.agent.endpoint().is_closed());
|
||||
assert!(matches!(
|
||||
a.agent.network_status(network_id).await,
|
||||
Err(Error::NetworkNotActive(_))
|
||||
));
|
||||
assert!(
|
||||
a.agent
|
||||
.send(
|
||||
network_id,
|
||||
b.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: 1,
|
||||
payload: Vec::new()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// Shutting down twice is harmless.
|
||||
a.agent.shutdown().await;
|
||||
|
||||
// The peer notices and carries on.
|
||||
let status = b.agent.network_status(network_id).await.unwrap();
|
||||
assert_eq!(status.state, tsunagi::agent::NetworkState::Active);
|
||||
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn several_independent_agents_coexist_in_one_process() {
|
||||
// No global state: two completely separate rendezvous tables, two networks
|
||||
// with the same name but different secrets, four agents, one process.
|
||||
let left = SharedMemoryDiscovery::new();
|
||||
let right = SharedMemoryDiscovery::new();
|
||||
let (name, left_secret) = network("same-name-different-world");
|
||||
let (_, right_secret) = network("ignored");
|
||||
|
||||
let l1 = TestAgent::spawn(&left).await.unwrap();
|
||||
let l2 = TestAgent::spawn(&left).await.unwrap();
|
||||
let r1 = TestAgent::spawn(&right).await.unwrap();
|
||||
let r2 = TestAgent::spawn(&right).await.unwrap();
|
||||
|
||||
let left_id = l1.agent.join_network(&name, &left_secret).await.unwrap();
|
||||
l2.agent.join_network(&name, &left_secret).await.unwrap();
|
||||
let right_id = r1.agent.join_network(&name, &right_secret).await.unwrap();
|
||||
r2.agent.join_network(&name, &right_secret).await.unwrap();
|
||||
assert_ne!(left_id, right_id);
|
||||
|
||||
wait_for_peers(&l1.agent, left_id, 1).await;
|
||||
wait_for_peers(&r1.agent, right_id, 1).await;
|
||||
assert_eq!(
|
||||
l1.agent
|
||||
.network_status(left_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.connected_peers(),
|
||||
vec![l2.agent.endpoint_id()]
|
||||
);
|
||||
|
||||
for agent in [l1, l2, r1, r2] {
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_agent_without_discovery_still_starts_and_serves_status() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let agent = Agent::spawn(local_config(dir.path())).await.unwrap();
|
||||
let (name, secret) = network("no-discovery");
|
||||
let network_id = agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let status = agent.network_status(network_id).await.unwrap();
|
||||
assert!(status.peers.is_empty());
|
||||
assert!(status.candidates.is_empty());
|
||||
agent.recheck().await;
|
||||
assert!(agent.recheck_network(network_id).await.is_ok());
|
||||
|
||||
agent.shutdown().await;
|
||||
drop(agent);
|
||||
drop(dir);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Scenarios 5 and 6: restart recovery and rotating the network secret.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{TestAgent, config_with, network, settle, wait_event, wait_for_peers};
|
||||
use tsunagi::agent::Event;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::{NetworkName, NetworkSecret};
|
||||
use tsunagi::proto::ControlMessage;
|
||||
use tsunagi::{Agent, Error};
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_restarted_agent_keeps_its_identity_and_reconnects() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("survives-restart");
|
||||
|
||||
let peer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let restarting = TestAgent::spawn(&discovery).await.unwrap();
|
||||
|
||||
let network_id = peer.agent.join_network(&name, &secret).await.unwrap();
|
||||
restarting.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
wait_for_peers(&peer.agent, network_id, 1).await;
|
||||
let device_id = restarting.agent.endpoint_id();
|
||||
let old_sockets = restarting.agent.status().await.unwrap().bound_sockets;
|
||||
|
||||
let mut peer_events = peer.agent.subscribe();
|
||||
let dir = restarting.stop().await;
|
||||
|
||||
// The surviving peer notices the session ending.
|
||||
wait_event(&mut peer_events, |event| match event {
|
||||
Event::PeerDisconnected { peer, .. } if *peer == device_id => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Restart from the same state directory. Binding to port zero again means a
|
||||
// different local UDP port, which the refreshed discovery entry covers.
|
||||
let restarted = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(restarted.endpoint_id(), device_id);
|
||||
let new_sockets = restarted.status().await.unwrap().bound_sockets;
|
||||
assert_ne!(old_sockets, new_sockets, "a fresh local port is expected");
|
||||
|
||||
// The configured network came back up on its own and both sides reconnect.
|
||||
assert!(restarted.is_active(network_id).await);
|
||||
wait_for_peers(&restarted, network_id, 1).await;
|
||||
wait_for_peers(&peer.agent, network_id, 1).await;
|
||||
|
||||
// And the restored session really works.
|
||||
restarted
|
||||
.send(
|
||||
network_id,
|
||||
peer.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: 5,
|
||||
payload: b"back".to_vec(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut events = restarted.subscribe();
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::MessageReceived {
|
||||
message: ControlMessage::Pong { seq: 5, payload },
|
||||
..
|
||||
} if payload == b"back" => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
restarted.shutdown().await;
|
||||
peer.agent.shutdown().await;
|
||||
drop(restarted);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_does_not_wait_for_anyone_else() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("lonely");
|
||||
|
||||
// No peers exist and no relay is reachable. The agent must still come up.
|
||||
let alone = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = alone.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let status = alone.agent.network_status(network_id).await.unwrap();
|
||||
assert!(status.peers.is_empty());
|
||||
assert_eq!(status.state, tsunagi::agent::NetworkState::Active);
|
||||
|
||||
alone.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rotating_the_secret_moves_everyone_to_a_new_space() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let name = NetworkName::new("rotate-me").unwrap();
|
||||
let old_secret = NetworkSecret::generate();
|
||||
let new_secret = NetworkSecret::generate();
|
||||
|
||||
let a = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let b = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let a_id = a.agent.endpoint_id();
|
||||
|
||||
let old = a.agent.join_network(&name, &old_secret).await.unwrap();
|
||||
b.agent.join_network(&name, &old_secret).await.unwrap();
|
||||
wait_for_peers(&a.agent, old, 1).await;
|
||||
|
||||
// Rotation through the public API: deactivate the old space, join the new.
|
||||
// No dedicated command is needed for this.
|
||||
a.agent.deactivate_network(old).await.unwrap();
|
||||
let new = a.agent.join_network(&name, &new_secret).await.unwrap();
|
||||
assert_ne!(old, new);
|
||||
assert_eq!(a.agent.endpoint_id(), a_id, "device identity is untouched");
|
||||
|
||||
// B still holds the old secret, so it must not reach the new space.
|
||||
settle().await;
|
||||
let status = a.agent.network_status(new).await.unwrap();
|
||||
assert!(
|
||||
status.peers.is_empty(),
|
||||
"the old secret must not open the new space"
|
||||
);
|
||||
assert!(
|
||||
a.agent
|
||||
.send(
|
||||
old,
|
||||
b.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: 1,
|
||||
payload: Vec::new()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"the deactivated network cannot be used any more"
|
||||
);
|
||||
|
||||
// Once B rotates too, they meet again in the new space.
|
||||
b.agent.deactivate_network(old).await.unwrap();
|
||||
let b_new = b.agent.join_network(&name, &new_secret).await.unwrap();
|
||||
assert_eq!(b_new, new);
|
||||
wait_for_peers(&a.agent, new, 1).await;
|
||||
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rotated_out_network_does_not_come_back_after_a_restart() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let name = NetworkName::new("no-resurrection").unwrap();
|
||||
|
||||
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let old = agent
|
||||
.agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
let new = agent
|
||||
.agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
agent.agent.deactivate_network(old).await.unwrap();
|
||||
let dir = agent.stop().await;
|
||||
|
||||
let restarted = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!restarted.is_active(old).await);
|
||||
assert!(restarted.is_active(new).await);
|
||||
assert!(matches!(
|
||||
restarted.network_status(old).await,
|
||||
Err(Error::NetworkNotActive(_))
|
||||
));
|
||||
|
||||
restarted.shutdown().await;
|
||||
drop(restarted);
|
||||
drop(dir);
|
||||
}
|
||||
Reference in New Issue
Block a user