The command line package is now `tsunagi` and builds the `tsng` executable, which the packages, the unit, the installers and the workflows install and run. The core library package becomes `tsunagi-core`, keeping the library name `tsunagi`, so no code changes `use tsunagi::...`. Directories follow (crates/tsunagi-core, crates/tsunagi). Every command shown to a person is now `tsng ...`. Co-Authored-By: Claude <noreply@anthropic.com>
335 lines
11 KiB
Rust
335 lines
11 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)]
|
|
|
|
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;
|
|
use tsunagi::testing::{TestAgent, network, settle, wait_event, wait_for_peers, wait_until};
|
|
|
|
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;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn two_networks_on_one_agent_each_get_a_range_of_their_own() {
|
|
// One agent, one interface: the configured range can only belong to one
|
|
// of them. The second does not go without — it takes the range derived
|
|
// from its own id, which every one of its members derives identically,
|
|
// so it is an agreement and not a local invention.
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
let (first, first_secret) = network("two-ranges-one");
|
|
let (second, second_secret) = network("two-ranges-two");
|
|
|
|
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
|
let one = agent
|
|
.agent
|
|
.join_network(&first, &first_secret)
|
|
.await
|
|
.unwrap();
|
|
let two = agent
|
|
.agent
|
|
.join_network(&second, &second_secret)
|
|
.await
|
|
.unwrap();
|
|
|
|
let own = agent.agent.endpoint_id();
|
|
let addresses = wait_until("both networks allocate an address", || {
|
|
let handle = agent.agent.clone();
|
|
async move {
|
|
let mine = async |network| {
|
|
handle
|
|
.network_status(network)
|
|
.await
|
|
.ok()?
|
|
.members
|
|
.into_iter()
|
|
.find(|member| member.endpoint_id == own)?
|
|
.overlay_address_v4
|
|
};
|
|
Some((mine(one).await?, mine(two).await?))
|
|
}
|
|
})
|
|
.await;
|
|
|
|
assert_ne!(
|
|
addresses.0, addresses.1,
|
|
"different networks, different addresses"
|
|
);
|
|
assert!(
|
|
tsunagi::state::DEFAULT_IPV4_RANGE.contains(addresses.0),
|
|
"the first keeps the configured range: {}",
|
|
addresses.0
|
|
);
|
|
assert!(
|
|
tsunagi::state::derived_ipv4_range(two).contains(addresses.1),
|
|
"the second is in the range derived from its id: {}",
|
|
addresses.1
|
|
);
|
|
assert!(
|
|
!tsunagi::state::DEFAULT_IPV4_RANGE.contains(addresses.1),
|
|
"and not in the one the first network holds"
|
|
);
|
|
|
|
// And the ranges do not overlap, which is what lets one interface
|
|
// carry both without a packet being ambiguous.
|
|
assert!(
|
|
!tsunagi::state::derived_ipv4_range(two).contains(addresses.0),
|
|
"the two ranges must not overlap"
|
|
);
|
|
|
|
agent.agent.shutdown().await;
|
|
}
|