2026-09-21 14:33:19 +01:00
|
|
|
//! The agent managing its own overlay interface.
|
|
|
|
|
//!
|
|
|
|
|
//! Everything here is real except the host: real agents, real control plane,
|
|
|
|
|
//! real iroh links, the real plugin lifecycle and the real reconciliation
|
|
|
|
|
//! rules. The host itself is a [`MockHost`], so what the agent would have
|
|
|
|
|
//! done to a machine's interfaces is asserted instead of done — which is how
|
|
|
|
|
//! this runs with no privileges and without touching the machine it is on.
|
|
|
|
|
//!
|
|
|
|
|
//! What the real provisioner adds on top of this is the netlink calls, and
|
|
|
|
|
//! only those.
|
|
|
|
|
|
|
|
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
|
|
|
|
|
|
use std::net::{IpAddr, Ipv4Addr};
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
use tsunagi::dataplane::IpPlugin;
|
|
|
|
|
use tsunagi::discovery::SharedMemoryDiscovery;
|
|
|
|
|
use tsunagi::identity::NetworkId;
|
2026-09-21 19:55:30 +01:00
|
|
|
use tsunagi::overlay::{
|
|
|
|
|
Cidr, InterfaceState, LinkKind, ManagedTunFactory, MockHost, MockProvisioner,
|
|
|
|
|
};
|
2026-09-21 14:33:19 +01:00
|
|
|
use tsunagi::state::DEFAULT_IPV4_RANGE;
|
2026-09-21 19:55:30 +01:00
|
|
|
use tsunagi::testing::{config_with, network, wait_until};
|
2026-09-21 14:33:19 +01:00
|
|
|
use tsunagi::{Agent, NetworkStatus};
|
2026-09-21 19:55:30 +01:00
|
|
|
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
|
2026-09-21 14:33:19 +01:00
|
|
|
|
|
|
|
|
/// An agent whose overlay interface is applied to a pretend host.
|
|
|
|
|
struct HostedAgent {
|
|
|
|
|
_dir: TempDir,
|
|
|
|
|
agent: Agent,
|
2026-09-21 19:20:30 +01:00
|
|
|
_plugin: Arc<WireguardPlugin>,
|
2026-09-21 14:33:19 +01:00
|
|
|
host: MockHost,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl HostedAgent {
|
|
|
|
|
async fn spawn(discovery: &SharedMemoryDiscovery, tag: &str, host: MockHost) -> Self {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let provisioner = Arc::new(MockProvisioner::new(host.clone()));
|
|
|
|
|
let factory = Arc::new(ManagedTunFactory::new(provisioner));
|
|
|
|
|
let config = WireguardConfig::new(dir.path().join("wireguard"))
|
|
|
|
|
.with_reconcile(Duration::from_millis(20), Duration::from_millis(100));
|
2026-09-21 19:20:30 +01:00
|
|
|
let plugin = WireguardPlugin::open(config).await.unwrap();
|
2026-09-21 14:33:19 +01:00
|
|
|
let agent = Agent::spawn(
|
|
|
|
|
config_with(dir.path(), discovery)
|
|
|
|
|
.with_overlay_ipv4_range(Some(DEFAULT_IPV4_RANGE))
|
2026-09-21 19:20:30 +01:00
|
|
|
.with_interface(factory, tag, 1280)
|
2026-09-21 14:33:19 +01:00
|
|
|
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
Self {
|
|
|
|
|
_dir: dir,
|
|
|
|
|
agent,
|
2026-09-21 19:20:30 +01:00
|
|
|
_plugin: plugin,
|
2026-09-21 14:33:19 +01:00
|
|
|
host,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The interface name the plugin settled on for a network.
|
2026-09-21 19:20:30 +01:00
|
|
|
/// The one interface this agent owns. Not per network.
|
|
|
|
|
async fn interface(&self, _network: NetworkId) -> String {
|
|
|
|
|
wait_until("the agent named its interface", || async {
|
|
|
|
|
self.agent
|
|
|
|
|
.overlay()
|
|
|
|
|
.map(|overlay| overlay.interface)
|
2026-09-21 14:33:19 +01:00
|
|
|
.filter(|name| !name.is_empty())
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Waits until the pretend host shows an interface in the given state.
|
|
|
|
|
async fn wait_for_host<T>(
|
|
|
|
|
&self,
|
|
|
|
|
what: &str,
|
|
|
|
|
name: &str,
|
|
|
|
|
probe: impl Fn(Option<InterfaceState>) -> Option<T>,
|
|
|
|
|
) -> T {
|
|
|
|
|
wait_until(what, || async { probe(self.host.get(name)) }).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn v4(state: &InterfaceState) -> Vec<Ipv4Addr> {
|
|
|
|
|
state
|
|
|
|
|
.addresses
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|cidr| match cidr.addr {
|
|
|
|
|
IpAddr::V4(addr) => Some(addr),
|
|
|
|
|
IpAddr::V6(_) => None,
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 18:40:49 +01:00
|
|
|
/// Whether the overlay address has been put on the interface yet.
|
|
|
|
|
///
|
|
|
|
|
/// It is allocated and signed at the system level, so it arrives on a later
|
|
|
|
|
/// reconciliation than the interface itself rather than with it.
|
|
|
|
|
fn addressed(state: &InterfaceState) -> bool {
|
|
|
|
|
!state.addresses.is_empty()
|
2026-09-21 14:33:19 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn an_agent_creates_and_configures_its_own_overlay_interface() {
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("provision-create");
|
|
|
|
|
let agent = HostedAgent::spawn(&discovery, "tsunp", MockHost::new()).await;
|
|
|
|
|
|
|
|
|
|
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
let interface = agent.interface(network_id).await;
|
|
|
|
|
|
|
|
|
|
let state = agent
|
2026-09-21 18:40:49 +01:00
|
|
|
.wait_for_host("the interface to be created", &interface, |state| state)
|
2026-09-21 14:33:19 +01:00
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
assert_eq!(state.kind, LinkKind::Tun);
|
|
|
|
|
assert!(state.up, "the agent brought the link up itself");
|
|
|
|
|
assert_eq!(state.mtu, 1280);
|
|
|
|
|
|
|
|
|
|
let addresses = agent
|
|
|
|
|
.wait_for_host("the allocated IPv4 address", &interface, |state| {
|
|
|
|
|
state.map(|state| v4(&state)).filter(|v4| !v4.is_empty())
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
assert_eq!(addresses.len(), 1);
|
|
|
|
|
assert!(
|
|
|
|
|
DEFAULT_IPV4_RANGE.contains(addresses[0]),
|
|
|
|
|
"{:?} is outside {DEFAULT_IPV4_RANGE}",
|
|
|
|
|
addresses[0]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
agent.agent.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn an_interface_left_by_a_crashed_run_is_replaced_rather_than_tripped_over() {
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("provision-crash");
|
|
|
|
|
|
|
|
|
|
// Work out the name this agent will use, then seed the host with what a
|
|
|
|
|
// run that died would have left there: the interface, still carrying an
|
|
|
|
|
// address from an allocation that no longer applies, with nothing holding
|
|
|
|
|
// it open.
|
|
|
|
|
let probe = HostedAgent::spawn(&discovery, "tsunc", MockHost::new()).await;
|
|
|
|
|
let network_id = probe.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
let interface = probe.interface(network_id).await;
|
|
|
|
|
probe.agent.shutdown().await;
|
|
|
|
|
|
|
|
|
|
let host = MockHost::new();
|
|
|
|
|
let stale = Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 178)), 24).unwrap();
|
|
|
|
|
host.insert_stale_tun(&interface, vec![stale]);
|
|
|
|
|
|
|
|
|
|
let agent = HostedAgent::spawn(&discovery, "tsunc", host).await;
|
|
|
|
|
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
assert_eq!(agent.interface(network_id).await, interface);
|
|
|
|
|
|
2026-09-21 18:40:49 +01:00
|
|
|
// Waited on directly rather than on "it has some address": the address
|
|
|
|
|
// this agent was allocated arrives a reconciliation after the interface
|
|
|
|
|
// does, and in between the leftover is still the only one there.
|
2026-09-21 14:33:19 +01:00
|
|
|
let state = agent
|
2026-09-21 18:40:49 +01:00
|
|
|
.wait_for_host("the stale address to be replaced", &interface, |state| {
|
|
|
|
|
state.filter(|state| {
|
|
|
|
|
state.attached && addressed(state) && !state.addresses.contains(&stale)
|
|
|
|
|
})
|
2026-09-21 14:33:19 +01:00
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
assert!(
|
|
|
|
|
!state.addresses.contains(&stale),
|
|
|
|
|
"the stale address is gone: {:?}",
|
|
|
|
|
state.addresses
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
agent.agent.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn an_interface_belonging_to_something_else_is_left_alone() {
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("provision-foreign");
|
|
|
|
|
|
|
|
|
|
let probe = HostedAgent::spawn(&discovery, "tsunf", MockHost::new()).await;
|
|
|
|
|
let network_id = probe.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
let interface = probe.interface(network_id).await;
|
|
|
|
|
probe.agent.shutdown().await;
|
|
|
|
|
|
|
|
|
|
// Somebody else's bridge happens to hold the name.
|
|
|
|
|
let host = MockHost::new();
|
|
|
|
|
let theirs = InterfaceState {
|
|
|
|
|
kind: LinkKind::Foreign("bridge".into()),
|
|
|
|
|
attached: true,
|
|
|
|
|
up: true,
|
|
|
|
|
mtu: 1500,
|
|
|
|
|
addresses: vec![Cidr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 9, 1)), 24).unwrap()],
|
|
|
|
|
};
|
|
|
|
|
host.insert(&interface, theirs.clone());
|
|
|
|
|
|
|
|
|
|
let agent = HostedAgent::spawn(&discovery, "tsunf", host).await;
|
|
|
|
|
agent.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
|
|
|
|
|
// Give the plugin several reconciliation rounds to do the wrong thing.
|
|
|
|
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
|
|
|
assert_eq!(
|
|
|
|
|
agent.host.get(&interface),
|
|
|
|
|
Some(theirs),
|
|
|
|
|
"the foreign interface must be untouched"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// The control plane is unaffected by the data plane refusing.
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
agent.agent.network_status(network_id).await,
|
|
|
|
|
Ok(NetworkStatus { .. })
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
agent.agent.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
2026-09-21 19:20:30 +01:00
|
|
|
async fn leaving_a_network_takes_its_address_off_the_interface_but_not_the_interface() {
|
|
|
|
|
// The interface belongs to the agent, so it outlives any one network:
|
|
|
|
|
// another may still be using it. What a network takes with it is its own
|
|
|
|
|
// address.
|
2026-09-21 14:33:19 +01:00
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("provision-cleanup");
|
|
|
|
|
let agent = HostedAgent::spawn(&discovery, "tsunx", MockHost::new()).await;
|
|
|
|
|
|
|
|
|
|
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
let interface = agent.interface(network_id).await;
|
|
|
|
|
agent
|
2026-09-21 19:20:30 +01:00
|
|
|
.wait_for_host("the address to be assigned", &interface, |state| {
|
|
|
|
|
state.filter(addressed)
|
|
|
|
|
})
|
2026-09-21 14:33:19 +01:00
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
agent.agent.deactivate_network(network_id).await.unwrap();
|
|
|
|
|
|
2026-09-21 19:20:30 +01:00
|
|
|
let state = agent
|
|
|
|
|
.wait_for_host("the address to be withdrawn", &interface, |state| {
|
|
|
|
|
state.filter(|state| !addressed(state))
|
2026-09-21 14:33:19 +01:00
|
|
|
})
|
|
|
|
|
.await;
|
2026-09-21 19:20:30 +01:00
|
|
|
assert_eq!(state.kind, LinkKind::Tun, "the interface is still there");
|
|
|
|
|
|
|
|
|
|
// And it goes when the agent does.
|
|
|
|
|
agent.agent.shutdown().await;
|
2026-09-21 14:33:19 +01:00
|
|
|
assert!(
|
|
|
|
|
agent.host.names().is_empty(),
|
|
|
|
|
"nothing is left behind: {:?}",
|
|
|
|
|
agent.host.names()
|
|
|
|
|
);
|
|
|
|
|
}
|