//! 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)] mod common; use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use std::time::Duration; use common::{config_with, network, wait_until}; use tempfile::TempDir; use tsunagi::dataplane::IpPlugin; use tsunagi::dataplane::wireguard::{ Cidr, InterfaceState, LinkKind, ManagedTunFactory, MockHost, MockProvisioner, WireguardConfig, WireguardPlugin, }; use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::identity::NetworkId; use tsunagi::state::DEFAULT_IPV4_RANGE; use tsunagi::{Agent, NetworkStatus}; /// An agent whose overlay interface is applied to a pretend host. struct HostedAgent { _dir: TempDir, agent: Agent, plugin: Arc, 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_interface_prefix(tag) .with_reconcile(Duration::from_millis(20), Duration::from_millis(100)); let plugin = WireguardPlugin::open(config, factory).await.unwrap(); let agent = Agent::spawn( config_with(dir.path(), discovery) .with_overlay_ipv4_range(Some(DEFAULT_IPV4_RANGE)) .with_plugin(plugin.clone() as Arc), ) .await .unwrap(); Self { _dir: dir, agent, plugin, host, } } /// The interface name the plugin settled on for a network. async fn interface(&self, network: NetworkId) -> String { wait_until("the plugin named its interface", || async { self.plugin .overview(network) .map(|view| view.interface) .filter(|name| !name.is_empty()) }) .await } /// Waits until the pretend host shows an interface in the given state. async fn wait_for_host( &self, what: &str, name: &str, probe: impl Fn(Option) -> Option, ) -> T { wait_until(what, || async { probe(self.host.get(name)) }).await } } fn v4(state: &InterfaceState) -> Vec { state .addresses .iter() .filter_map(|cidr| match cidr.addr { IpAddr::V4(addr) => Some(addr), IpAddr::V6(_) => None, }) .collect() } fn has_v6(state: &InterfaceState) -> bool { state .addresses .iter() .any(|cidr| matches!(cidr.addr, IpAddr::V6(_))) } #[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 .wait_for_host("the interface to be created", &interface, |state| { state.filter(|state| !state.addresses.is_empty()) }) .await; assert_eq!(state.kind, LinkKind::Tun); assert!(state.up, "the agent brought the link up itself"); assert_eq!(state.mtu, 1280); assert!(has_v6(&state), "the derived overlay address is assigned"); // The IPv4 address is allocated at run time, so it arrives on a later // reconciliation than the interface itself. 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); let state = agent .wait_for_host("the interface to be rebuilt", &interface, |state| { state.filter(|state| state.attached && has_v6(state)) }) .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] async fn leaving_a_network_removes_the_interface_from_the_host() { 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 .wait_for_host("the interface to exist", &interface, |state| state) .await; agent.agent.deactivate_network(network_id).await.unwrap(); agent .wait_for_host("the interface to be removed", &interface, |state| { state.is_none().then_some(()) }) .await; assert!( agent.host.names().is_empty(), "nothing is left behind: {:?}", agent.host.names() ); agent.agent.shutdown().await; }