Every first network previously proposed 10.13.37.0/24. Independently created networks therefore collided when a device joined more than one of them. Use the existing stable NetworkId-derived /24 in 10/8 for every new network, independent of activation order or peer availability. Legacy and explicit signed ranges outrank the derived default; remaining range disagreements still use author-id ordering. The derivation, identity labels, handshake and signed record encoding stay unchanged. There is no creator, timestamp or vote. Reserve ranges before starting network actors and serialise start, stop and switching through the agent lifecycle lock. Refuse equal or nested overlaps without changing a network's subnet locally. A late authenticated range that collides suspends only its network before updating plugins or the interface. Coalesced watch notifications let the agent clean up outside the network actor and reconsider suspended networks when ranges change. An explicit switch stops every blocker before activating the chosen network. Suspended networks retain their startup request and resume whenever their ranges become free, including switching from a wide range to a narrow one. A manual stop cancels that request even for an already suspended network. Persist preference order and enabling together in an SQLite transaction so the user's selection survives restart; no database schema migration is needed. CLI status and network listing show blocker names, ids, ranges and the start command. Network start switches explicitly and reports suspended networks; joining an overlapping network reports it as configured but inactive. The tray adds conflict details, an amber icon, Switch to this network and Keep stopped actions. Local IPC wire version is 19 for the extended reports, so restart the agent and tray together. Release versions and peer wire formats are unchanged. Update the user documentation and compatibility tests. Validation on Linux through nix develop: - cargo fmt --all -- --check - cargo clippy --locked --workspace --all-targets -- -D warnings - cargo test --locked --workspace --all-targets with TSUNAGI_CONTROL_SOCKET removed from the test environment: 486 passed, 2 pre-existing ignored tests - final focused range_conflicts integration suite after making discovery fixtures deterministic: 6 passed Earlier validation attempts caught outdated diagnostic assertions, a new test helper scope error, and the inherited system control socket override. Those were corrected before the successful runs. Plain cargo was unavailable in the host shell, so validation used the Nix development toolchain. macOS, Windows, privileged host TUN operations and manual tray interaction were not exercised.
332 lines
11 KiB
Rust
332 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() {
|
|
// Each network derives its range independently of activation order.
|
|
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::derived_ipv4_range(one).contains(addresses.0),
|
|
"the first uses its derived 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::derived_ipv4_range(one).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;
|
|
}
|