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>
267 lines
8.6 KiB
Rust
267 lines
8.6 KiB
Rust
//! 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;
|
|
}
|