2026-09-21 11:55:20 +01:00
|
|
|
//! The WireGuard data plane, driven over real iroh connections.
|
2026-09-21 11:07:31 +01:00
|
|
|
//!
|
2026-09-21 11:55:20 +01:00
|
|
|
//! Everything here is real except the packet interface: real agents, real
|
|
|
|
|
//! control plane, real iroh data links, real WireGuard handshakes and
|
|
|
|
|
//! encryption from `boringtun`. Only the TUN device is in memory, which is why
|
|
|
|
|
//! the whole data plane can be tested with no privileges and without touching
|
|
|
|
|
//! the host's network.
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
|
|
2026-09-21 18:40:49 +01:00
|
|
|
use std::net::Ipv4Addr;
|
2026-09-21 11:07:31 +01:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
use bytes::Bytes;
|
2026-09-21 11:07:31 +01:00
|
|
|
use iroh::EndpointId;
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
use tsunagi::agent::Event;
|
2026-09-21 11:55:20 +01:00
|
|
|
use tsunagi::dataplane::IpPlugin;
|
2026-09-21 11:07:31 +01:00
|
|
|
use tsunagi::discovery::SharedMemoryDiscovery;
|
|
|
|
|
use tsunagi::identity::{NetworkId, NetworkName, NetworkSecret};
|
2026-09-21 21:21:55 +01:00
|
|
|
use tsunagi::overlay::{
|
|
|
|
|
MemoryTun, MemoryTunFactory, OverlayError, TunDevice, TunFactory, TunRequest,
|
|
|
|
|
};
|
2026-09-21 19:55:30 +01:00
|
|
|
use tsunagi::state::Ipv4Range;
|
|
|
|
|
use tsunagi::testing::{config_with, network, settle, wait_event, wait_for_peers, wait_until};
|
2026-09-21 11:07:31 +01:00
|
|
|
use tsunagi::{Agent, NetworkStatus};
|
2026-09-21 19:55:30 +01:00
|
|
|
use tsunagi_wg_quic::{
|
|
|
|
|
WIREGUARD_PROTOCOL, WgAnnouncement, WgSecretKey, WireguardConfig, WireguardPlugin,
|
|
|
|
|
};
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 21:21:55 +01:00
|
|
|
/// A factory that claims the host and puts nothing on it.
|
|
|
|
|
///
|
|
|
|
|
/// This is the case the missing-address report exists for: a provisioner
|
|
|
|
|
/// that returned success without achieving it, or something outside that
|
|
|
|
|
/// removed the address afterwards. An in-memory interface is *not* that
|
|
|
|
|
/// case — it has no host side at all, and treating the two as one is what
|
|
|
|
|
/// had the agent reporting a fault about `--no-tun` working as intended,
|
|
|
|
|
/// and configuring host interfaces it never created.
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
struct PretendHostTuns(MemoryTunFactory);
|
|
|
|
|
|
|
|
|
|
impl TunFactory for PretendHostTuns {
|
|
|
|
|
fn name(&self) -> &str {
|
|
|
|
|
"pretend-host"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn on_host(&self) -> bool {
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn create<'a>(
|
|
|
|
|
&'a self,
|
|
|
|
|
request: TunRequest,
|
|
|
|
|
) -> tsunagi::BoxFuture<'a, Result<Arc<dyn TunDevice>, OverlayError>> {
|
|
|
|
|
self.0.create(request)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn reconfigure<'a>(
|
|
|
|
|
&'a self,
|
|
|
|
|
request: TunRequest,
|
|
|
|
|
) -> tsunagi::BoxFuture<'a, Result<(), OverlayError>> {
|
|
|
|
|
self.0.reconfigure(request)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn destroy<'a>(&'a self, name: &'a str) -> tsunagi::BoxFuture<'a, ()> {
|
|
|
|
|
self.0.destroy(name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
/// An agent with a WireGuard plugin backed by an in-memory packet interface.
|
2026-09-21 11:07:31 +01:00
|
|
|
struct WgAgent {
|
|
|
|
|
dir: TempDir,
|
|
|
|
|
agent: Agent,
|
|
|
|
|
plugin: Arc<WireguardPlugin>,
|
2026-09-21 11:55:20 +01:00
|
|
|
tuns: MemoryTunFactory,
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl WgAgent {
|
2026-09-21 11:55:20 +01:00
|
|
|
async fn spawn(discovery: &SharedMemoryDiscovery, tag: &str) -> Self {
|
|
|
|
|
Self::spawn_with(discovery, tag, |config| config).await
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn spawn_with(
|
|
|
|
|
discovery: &SharedMemoryDiscovery,
|
|
|
|
|
tag: &str,
|
|
|
|
|
tune: impl FnOnce(WireguardConfig) -> WireguardConfig,
|
2026-09-21 13:43:01 +01:00
|
|
|
) -> Self {
|
|
|
|
|
Self::spawn_full(
|
|
|
|
|
discovery,
|
|
|
|
|
tag,
|
|
|
|
|
tune,
|
|
|
|
|
Some(tsunagi::state::DEFAULT_IPV4_RANGE),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Starts an agent with an explicit overlay range, or none at all.
|
|
|
|
|
async fn spawn_range(
|
|
|
|
|
discovery: &SharedMemoryDiscovery,
|
|
|
|
|
tag: &str,
|
|
|
|
|
range: Option<Ipv4Range>,
|
|
|
|
|
) -> Self {
|
|
|
|
|
Self::spawn_full(discovery, tag, |config| config, range).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn spawn_full(
|
|
|
|
|
discovery: &SharedMemoryDiscovery,
|
|
|
|
|
tag: &str,
|
|
|
|
|
tune: impl FnOnce(WireguardConfig) -> WireguardConfig,
|
|
|
|
|
range: Option<Ipv4Range>,
|
2026-09-21 11:07:31 +01:00
|
|
|
) -> Self {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
2026-09-21 13:43:01 +01:00
|
|
|
let (agent, plugin, tuns) = Self::open_with(dir.path(), discovery, tag, tune, range).await;
|
2026-09-21 11:07:31 +01:00
|
|
|
Self {
|
|
|
|
|
dir,
|
|
|
|
|
agent,
|
|
|
|
|
plugin,
|
2026-09-21 11:55:20 +01:00
|
|
|
tuns,
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn open(
|
|
|
|
|
root: &std::path::Path,
|
|
|
|
|
discovery: &SharedMemoryDiscovery,
|
|
|
|
|
tag: &str,
|
|
|
|
|
tune: impl FnOnce(WireguardConfig) -> WireguardConfig,
|
2026-09-21 13:43:01 +01:00
|
|
|
) -> (Agent, Arc<WireguardPlugin>, MemoryTunFactory) {
|
|
|
|
|
Self::open_with(
|
|
|
|
|
root,
|
|
|
|
|
discovery,
|
|
|
|
|
tag,
|
|
|
|
|
tune,
|
|
|
|
|
Some(tsunagi::state::DEFAULT_IPV4_RANGE),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn open_with(
|
|
|
|
|
root: &std::path::Path,
|
|
|
|
|
discovery: &SharedMemoryDiscovery,
|
|
|
|
|
tag: &str,
|
|
|
|
|
tune: impl FnOnce(WireguardConfig) -> WireguardConfig,
|
|
|
|
|
range: Option<Ipv4Range>,
|
2026-09-21 11:55:20 +01:00
|
|
|
) -> (Agent, Arc<WireguardPlugin>, MemoryTunFactory) {
|
|
|
|
|
let tuns = MemoryTunFactory::new();
|
2026-09-21 11:07:31 +01:00
|
|
|
let wg = tune(
|
|
|
|
|
WireguardConfig::new(root.join("wireguard"))
|
|
|
|
|
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
|
|
|
|
|
);
|
2026-09-21 19:20:30 +01:00
|
|
|
let plugin = WireguardPlugin::open(wg).await.unwrap();
|
|
|
|
|
// The interface belongs to the agent now, so the tag names it
|
|
|
|
|
// directly rather than prefixing one per network.
|
2026-09-21 11:55:20 +01:00
|
|
|
let agent = Agent::spawn(
|
2026-09-21 13:43:01 +01:00
|
|
|
config_with(root, discovery)
|
|
|
|
|
.with_overlay_ipv4_range(range)
|
2026-09-21 19:20:30 +01:00
|
|
|
.with_interface(Arc::new(tuns.clone()), tag, 1280)
|
2026-09-21 13:43:01 +01:00
|
|
|
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
|
2026-09-21 11:55:20 +01:00
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
(agent, plugin, tuns)
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
|
|
|
|
|
2026-09-22 01:32:43 +01:00
|
|
|
/// An agent that refuses a direct data path to some peers.
|
|
|
|
|
///
|
|
|
|
|
/// The one arrangement a single host cannot produce by itself: two
|
|
|
|
|
/// agents that both reach a third and not each other. The control
|
|
|
|
|
/// plane is untouched — they are members and they talk — only the
|
|
|
|
|
/// direct data link is refused, which is the real-world case of a
|
|
|
|
|
/// blocked or unreachable data path.
|
|
|
|
|
async fn spawn_cut_off(
|
|
|
|
|
discovery: &SharedMemoryDiscovery,
|
|
|
|
|
tag: &str,
|
|
|
|
|
blocked: Arc<std::sync::Mutex<std::collections::HashSet<EndpointId>>>,
|
|
|
|
|
) -> Self {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let tuns = MemoryTunFactory::new();
|
|
|
|
|
let plugin = WireguardPlugin::open(
|
|
|
|
|
WireguardConfig::new(dir.path().join("wireguard"))
|
|
|
|
|
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
let agent = Agent::spawn(
|
|
|
|
|
config_with(dir.path(), discovery)
|
|
|
|
|
.with_overlay_ipv4_range(Some(tsunagi::state::DEFAULT_IPV4_RANGE))
|
|
|
|
|
.with_interface(Arc::new(tuns.clone()), tag, 1280)
|
|
|
|
|
.with_unreachable_data_peers(blocked)
|
|
|
|
|
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
Self {
|
|
|
|
|
dir,
|
|
|
|
|
agent,
|
|
|
|
|
plugin,
|
|
|
|
|
tuns,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 21:21:55 +01:00
|
|
|
/// An agent whose interface claims to be on the host but is not.
|
|
|
|
|
///
|
|
|
|
|
/// Used only by the missing-address test: everywhere else the in-memory
|
|
|
|
|
/// interface is honest about what it is.
|
|
|
|
|
async fn spawn_pretending_host(discovery: &SharedMemoryDiscovery, tag: &str) -> Self {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let tuns = MemoryTunFactory::new();
|
|
|
|
|
let plugin = WireguardPlugin::open(
|
|
|
|
|
WireguardConfig::new(dir.path().join("wireguard"))
|
|
|
|
|
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
let agent = Agent::spawn(
|
|
|
|
|
config_with(dir.path(), discovery)
|
|
|
|
|
.with_overlay_ipv4_range(Some(tsunagi::state::DEFAULT_IPV4_RANGE))
|
|
|
|
|
.with_interface(Arc::new(PretendHostTuns(tuns.clone())), tag, 1280)
|
|
|
|
|
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
Self {
|
|
|
|
|
dir,
|
|
|
|
|
agent,
|
|
|
|
|
plugin,
|
|
|
|
|
tuns,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 11:07:31 +01:00
|
|
|
fn endpoint_id(&self) -> EndpointId {
|
|
|
|
|
self.agent.endpoint_id()
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
/// This agent's overlay address in a network.
|
2026-09-21 18:40:49 +01:00
|
|
|
///
|
|
|
|
|
/// Allocated and signed at the system level, so it appears once the
|
|
|
|
|
/// network has agreed on it rather than the moment the plugin starts.
|
|
|
|
|
async fn overlay(&self, network: NetworkId) -> Ipv4Addr {
|
|
|
|
|
wait_until("the network agreed an overlay address", || async {
|
|
|
|
|
self.plugin.overview(network)?.overlay_address_v4
|
2026-09-21 11:07:31 +01:00
|
|
|
})
|
2026-09-21 18:40:49 +01:00
|
|
|
.await
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
|
|
|
|
|
2026-09-21 19:20:30 +01:00
|
|
|
/// The one in-memory packet interface this agent owns.
|
|
|
|
|
///
|
|
|
|
|
/// Not per network: one agent has one interface, and which network a
|
|
|
|
|
/// packet on it belongs to is decided by its address.
|
|
|
|
|
async fn tun(&self, _network: NetworkId) -> Arc<MemoryTun> {
|
2026-09-21 11:55:20 +01:00
|
|
|
let name = wait_until("the packet interface exists", || async {
|
2026-09-21 19:20:30 +01:00
|
|
|
self.agent.overlay().map(|overlay| overlay.interface)
|
2026-09-21 11:55:20 +01:00
|
|
|
})
|
|
|
|
|
.await;
|
2026-09-21 19:20:30 +01:00
|
|
|
self.tuns.device(&name).expect("the device was created")
|
2026-09-21 11:55:20 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Waits until `count` tunnels have completed a WireGuard handshake.
|
|
|
|
|
async fn wait_for_tunnels(&self, network: NetworkId, count: usize) {
|
2026-09-21 11:07:31 +01:00
|
|
|
wait_until(
|
2026-09-21 11:55:20 +01:00
|
|
|
&format!("{count} established WireGuard tunnels"),
|
2026-09-21 11:07:31 +01:00
|
|
|
|| async {
|
2026-09-21 11:55:20 +01:00
|
|
|
let view = self.plugin.overview(network)?;
|
|
|
|
|
(view.established_peers() == count).then_some(())
|
2026-09-21 11:07:31 +01:00
|
|
|
},
|
|
|
|
|
)
|
2026-09-21 11:55:20 +01:00
|
|
|
.await;
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn shutdown(self) -> TempDir {
|
|
|
|
|
self.agent.shutdown().await;
|
|
|
|
|
self.dir
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 13:00:35 +01:00
|
|
|
/// Builds a minimal well-formed IPv4 packet.
|
|
|
|
|
fn ipv4_packet(source: Ipv4Addr, destination: Ipv4Addr, payload: &[u8]) -> Bytes {
|
|
|
|
|
let total = 20 + payload.len();
|
|
|
|
|
let mut packet = Vec::with_capacity(total);
|
|
|
|
|
packet.push((4 << 4) | 5); // version 4, header length 5 words
|
|
|
|
|
packet.push(0); // dscp/ecn
|
|
|
|
|
packet.extend_from_slice(&(total as u16).to_be_bytes());
|
|
|
|
|
packet.extend_from_slice(&[0, 0]); // identification
|
|
|
|
|
packet.extend_from_slice(&[0, 0]); // flags and fragment offset
|
|
|
|
|
packet.push(64); // ttl
|
|
|
|
|
packet.push(253); // an experimental protocol number
|
|
|
|
|
packet.extend_from_slice(&[0, 0]); // checksum, not verified by the overlay
|
|
|
|
|
packet.extend_from_slice(&source.octets());
|
|
|
|
|
packet.extend_from_slice(&destination.octets());
|
|
|
|
|
packet.extend_from_slice(payload);
|
|
|
|
|
Bytes::from(packet)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 11:07:31 +01:00
|
|
|
#[tokio::test]
|
2026-09-21 11:55:20 +01:00
|
|
|
async fn two_agents_carry_real_ip_packets_through_a_wireguard_tunnel() {
|
2026-09-21 11:07:31 +01:00
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
2026-09-21 11:55:20 +01:00
|
|
|
let (name, secret) = network("wg-traffic");
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
let a = WgAgent::spawn(&discovery, "ta").await;
|
|
|
|
|
let b = WgAgent::spawn(&discovery, "tb").await;
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
b.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
wait_for_peers(&a.agent, network_id, 1).await;
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
// Both tunnels must actually handshake, not merely be configured.
|
|
|
|
|
a.wait_for_tunnels(network_id, 1).await;
|
|
|
|
|
b.wait_for_tunnels(network_id, 1).await;
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
let addr_a = a.overlay(network_id).await;
|
|
|
|
|
let addr_b = b.overlay(network_id).await;
|
|
|
|
|
assert_ne!(addr_a, addr_b);
|
2026-09-21 18:40:49 +01:00
|
|
|
// One shared range, agreed at the system level and signed by each
|
|
|
|
|
// member, not derived from anything either protocol holds.
|
|
|
|
|
let range = a.plugin.overview(network_id).unwrap().ipv4_range.unwrap();
|
|
|
|
|
assert!(range.contains(addr_a) && range.contains(addr_b));
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
let tun_a = a.tun(network_id).await;
|
|
|
|
|
let tun_b = b.tun(network_id).await;
|
|
|
|
|
|
|
|
|
|
// A real IP packet, encrypted by WireGuard, carried over iroh, decrypted
|
|
|
|
|
// on the other side and handed to that host's packet interface.
|
|
|
|
|
let payload = b"hello over the overlay";
|
2026-09-21 18:40:49 +01:00
|
|
|
tun_a.push_from_os(ipv4_packet(addr_a, addr_b, payload));
|
2026-09-21 11:55:20 +01:00
|
|
|
|
2026-09-21 19:55:30 +01:00
|
|
|
let received = tokio::time::timeout(tsunagi::testing::DEADLINE, tun_b.pop_to_os())
|
2026-09-21 11:07:31 +01:00
|
|
|
.await
|
2026-09-21 11:55:20 +01:00
|
|
|
.expect("the packet should arrive")
|
|
|
|
|
.expect("the interface should still be open");
|
2026-09-21 18:40:49 +01:00
|
|
|
assert_eq!(&received[20..], payload);
|
|
|
|
|
assert_eq!(&received[12..16], &addr_a.octets(), "source preserved");
|
|
|
|
|
assert_eq!(&received[16..20], &addr_b.octets(), "destination preserved");
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
// And back the other way.
|
2026-09-21 18:40:49 +01:00
|
|
|
tun_b.push_from_os(ipv4_packet(addr_b, addr_a, b"and back"));
|
2026-09-21 19:55:30 +01:00
|
|
|
let back = tokio::time::timeout(tsunagi::testing::DEADLINE, tun_a.pop_to_os())
|
2026-09-21 11:55:20 +01:00
|
|
|
.await
|
|
|
|
|
.expect("the reply should arrive")
|
|
|
|
|
.unwrap();
|
2026-09-21 18:40:49 +01:00
|
|
|
assert_eq!(&back[20..], b"and back");
|
2026-09-21 11:55:20 +01:00
|
|
|
|
|
|
|
|
let view = a.plugin.overview(network_id).unwrap();
|
|
|
|
|
let tunnel = view.peers[0].tunnel.as_ref().unwrap();
|
|
|
|
|
assert!(tunnel.health.is_up());
|
|
|
|
|
assert!(tunnel.stats.tx_packets >= 1);
|
|
|
|
|
assert!(tunnel.stats.rx_packets >= 1);
|
|
|
|
|
assert_eq!(tunnel.stats.dropped_wrong_source, 0);
|
|
|
|
|
|
|
|
|
|
a.shutdown().await;
|
|
|
|
|
b.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn a_peer_cannot_send_from_an_address_it_does_not_own() {
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-spoof");
|
|
|
|
|
|
|
|
|
|
let a = WgAgent::spawn(&discovery, "ta").await;
|
|
|
|
|
let b = WgAgent::spawn(&discovery, "tb").await;
|
|
|
|
|
|
|
|
|
|
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
b.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
a.wait_for_tunnels(network_id, 1).await;
|
|
|
|
|
b.wait_for_tunnels(network_id, 1).await;
|
|
|
|
|
|
|
|
|
|
let addr_a = a.overlay(network_id).await;
|
|
|
|
|
let addr_b = b.overlay(network_id).await;
|
|
|
|
|
let tun_a = a.tun(network_id).await;
|
|
|
|
|
let tun_b = b.tun(network_id).await;
|
|
|
|
|
|
|
|
|
|
// A sends a packet claiming to come from a third party's address.
|
2026-09-21 18:40:49 +01:00
|
|
|
let someone_else = {
|
2026-09-21 11:55:20 +01:00
|
|
|
let mut octets = addr_a.octets();
|
2026-09-21 18:40:49 +01:00
|
|
|
octets[3] ^= 0xff;
|
|
|
|
|
Ipv4Addr::from(octets)
|
2026-09-21 11:55:20 +01:00
|
|
|
};
|
2026-09-21 18:40:49 +01:00
|
|
|
tun_a.push_from_os(ipv4_packet(someone_else, addr_b, b"spoofed"));
|
2026-09-21 11:55:20 +01:00
|
|
|
|
2026-09-21 18:40:49 +01:00
|
|
|
// B must drop it: the source is not the address A holds.
|
2026-09-21 19:20:30 +01:00
|
|
|
// Counted on the interface, not on the tunnel: the protocol proved who
|
|
|
|
|
// sent the packet, and whether that member may use the address it chose
|
|
|
|
|
// is a question about a signed claim, which the system level holds.
|
2026-09-21 11:55:20 +01:00
|
|
|
wait_until("the spoofed packet is dropped", || async {
|
2026-09-21 19:20:30 +01:00
|
|
|
(b.agent.overlay()?.counters.wrong_source >= 1).then_some(())
|
2026-09-21 11:55:20 +01:00
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
// A legitimate packet still goes through, so the tunnel is not broken.
|
2026-09-21 18:40:49 +01:00
|
|
|
tun_a.push_from_os(ipv4_packet(addr_a, addr_b, b"honest"));
|
2026-09-21 19:55:30 +01:00
|
|
|
let received = tokio::time::timeout(tsunagi::testing::DEADLINE, tun_b.pop_to_os())
|
2026-09-21 11:55:20 +01:00
|
|
|
.await
|
|
|
|
|
.expect("the honest packet should arrive")
|
|
|
|
|
.unwrap();
|
2026-09-21 18:40:49 +01:00
|
|
|
assert_eq!(&received[20..], b"honest");
|
2026-09-21 11:55:20 +01:00
|
|
|
|
|
|
|
|
a.shutdown().await;
|
|
|
|
|
b.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 13:00:35 +01:00
|
|
|
#[tokio::test]
|
2026-09-21 18:40:49 +01:00
|
|
|
async fn the_overlay_uses_a_range_the_network_was_told_to_use() {
|
2026-09-21 13:00:35 +01:00
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-dual-stack");
|
|
|
|
|
|
2026-09-21 13:11:09 +01:00
|
|
|
let range = Some("10.77.0.0/16".parse::<Ipv4Range>().unwrap());
|
2026-09-21 13:43:01 +01:00
|
|
|
let a = WgAgent::spawn_range(&discovery, "ta", range).await;
|
|
|
|
|
let b = WgAgent::spawn_range(&discovery, "tb", range).await;
|
2026-09-21 13:00:35 +01:00
|
|
|
|
|
|
|
|
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
b.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
a.wait_for_tunnels(network_id, 1).await;
|
|
|
|
|
b.wait_for_tunnels(network_id, 1).await;
|
|
|
|
|
|
|
|
|
|
let view_a = a.plugin.overview(network_id).unwrap();
|
|
|
|
|
let view_b = b.plugin.overview(network_id).unwrap();
|
|
|
|
|
|
2026-09-21 13:11:09 +01:00
|
|
|
let v4_a = view_a.overlay_address_v4.expect("ipv4 was configured");
|
|
|
|
|
let v4_b = view_b.overlay_address_v4.expect("ipv4 was configured");
|
2026-09-21 13:00:35 +01:00
|
|
|
assert_ne!(v4_a, v4_b);
|
|
|
|
|
// Both inside the configured range.
|
|
|
|
|
for addr in [v4_a, v4_b] {
|
|
|
|
|
assert_eq!(
|
2026-09-21 13:11:09 +01:00
|
|
|
u32::from(addr) & 0xffff_0000,
|
|
|
|
|
u32::from(Ipv4Addr::new(10, 77, 0, 0))
|
2026-09-21 13:00:35 +01:00
|
|
|
);
|
|
|
|
|
}
|
2026-09-21 18:40:49 +01:00
|
|
|
// Each side learned the other's address from the same signed state.
|
2026-09-21 13:00:35 +01:00
|
|
|
assert_eq!(view_a.peers[0].overlay_address_v4, Some(v4_b));
|
|
|
|
|
assert_eq!(view_b.peers[0].overlay_address_v4, Some(v4_a));
|
|
|
|
|
|
|
|
|
|
let tun_a = a.tun(network_id).await;
|
|
|
|
|
let tun_b = b.tun(network_id).await;
|
|
|
|
|
|
|
|
|
|
// A real IPv4 packet through the same tunnel.
|
|
|
|
|
tun_a.push_from_os(ipv4_packet(v4_a, v4_b, b"ipv4 over the overlay"));
|
2026-09-21 19:55:30 +01:00
|
|
|
let received = tokio::time::timeout(tsunagi::testing::DEADLINE, tun_b.pop_to_os())
|
2026-09-21 13:00:35 +01:00
|
|
|
.await
|
|
|
|
|
.expect("the IPv4 packet should arrive")
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(received[0] >> 4, 4, "still an IPv4 packet");
|
|
|
|
|
assert_eq!(&received[12..16], &v4_a.octets());
|
|
|
|
|
assert_eq!(&received[16..20], &v4_b.octets());
|
|
|
|
|
assert_eq!(&received[20..], b"ipv4 over the overlay");
|
|
|
|
|
|
|
|
|
|
a.shutdown().await;
|
|
|
|
|
b.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn an_ipv4_source_a_peer_does_not_own_is_dropped() {
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-v4-spoof");
|
|
|
|
|
|
2026-09-21 13:11:09 +01:00
|
|
|
let range = Some("10.78.0.0/16".parse::<Ipv4Range>().unwrap());
|
2026-09-21 13:43:01 +01:00
|
|
|
let a = WgAgent::spawn_range(&discovery, "ta", range).await;
|
|
|
|
|
let b = WgAgent::spawn_range(&discovery, "tb", range).await;
|
2026-09-21 13:00:35 +01:00
|
|
|
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
b.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
a.wait_for_tunnels(network_id, 1).await;
|
|
|
|
|
b.wait_for_tunnels(network_id, 1).await;
|
|
|
|
|
|
|
|
|
|
let v4_a = a
|
|
|
|
|
.plugin
|
|
|
|
|
.overview(network_id)
|
|
|
|
|
.unwrap()
|
|
|
|
|
.overlay_address_v4
|
|
|
|
|
.unwrap();
|
|
|
|
|
let v4_b = b
|
|
|
|
|
.plugin
|
|
|
|
|
.overview(network_id)
|
|
|
|
|
.unwrap()
|
|
|
|
|
.overlay_address_v4
|
|
|
|
|
.unwrap();
|
|
|
|
|
let tun_a = a.tun(network_id).await;
|
|
|
|
|
let tun_b = b.tun(network_id).await;
|
|
|
|
|
|
|
|
|
|
// A claims an IPv4 address that is not the one derived from its key.
|
|
|
|
|
let forged = Ipv4Addr::from(u32::from(v4_a) ^ 0x0000_00ff);
|
|
|
|
|
tun_a.push_from_os(ipv4_packet(forged, v4_b, b"spoofed v4"));
|
|
|
|
|
|
|
|
|
|
wait_until("the spoofed IPv4 packet is dropped", || async {
|
2026-09-21 19:20:30 +01:00
|
|
|
(b.agent.overlay()?.counters.wrong_source >= 1).then_some(())
|
2026-09-21 13:00:35 +01:00
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
// The honest one still gets through.
|
|
|
|
|
tun_a.push_from_os(ipv4_packet(v4_a, v4_b, b"honest v4"));
|
2026-09-21 19:55:30 +01:00
|
|
|
let received = tokio::time::timeout(tsunagi::testing::DEADLINE, tun_b.pop_to_os())
|
2026-09-21 13:00:35 +01:00
|
|
|
.await
|
|
|
|
|
.expect("the honest packet should arrive")
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(&received[20..], b"honest v4");
|
|
|
|
|
|
|
|
|
|
a.shutdown().await;
|
|
|
|
|
b.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 13:11:09 +01:00
|
|
|
#[tokio::test]
|
2026-09-21 13:43:01 +01:00
|
|
|
async fn a_joining_member_adopts_the_range_the_network_already_uses() {
|
2026-09-21 13:11:09 +01:00
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
2026-09-21 13:43:01 +01:00
|
|
|
let (name, secret) = network("wg-range-adopted");
|
2026-09-21 13:11:09 +01:00
|
|
|
|
2026-09-21 13:43:01 +01:00
|
|
|
// The two were started with different ranges. Rather than misroute, they
|
|
|
|
|
// converge on one, and every replica computes the same answer.
|
|
|
|
|
let a = WgAgent::spawn_range(&discovery, "ta", Some("10.80.0.0/16".parse().unwrap())).await;
|
|
|
|
|
let b = WgAgent::spawn_range(&discovery, "tb", Some("10.81.0.0/16".parse().unwrap())).await;
|
2026-09-21 13:11:09 +01:00
|
|
|
|
|
|
|
|
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
b.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
a.wait_for_tunnels(network_id, 1).await;
|
2026-09-21 13:43:01 +01:00
|
|
|
b.wait_for_tunnels(network_id, 1).await;
|
2026-09-21 13:11:09 +01:00
|
|
|
|
2026-09-21 13:43:01 +01:00
|
|
|
let agreed = wait_until("both settle on one range", || async {
|
|
|
|
|
let one = a.plugin.overview(network_id)?.ipv4_range?;
|
|
|
|
|
let two = b.plugin.overview(network_id)?.ipv4_range?;
|
|
|
|
|
(one == two).then_some(one)
|
2026-09-21 13:11:09 +01:00
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
|
2026-09-21 13:43:01 +01:00
|
|
|
// Whichever won, both hold an address inside it, and they differ.
|
|
|
|
|
let view_a = wait_until("a has an address in the agreed range", || async {
|
|
|
|
|
let view = a.plugin.overview(network_id)?;
|
|
|
|
|
let address = view.overlay_address_v4?;
|
|
|
|
|
agreed.contains(address).then_some(view)
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
let view_b = wait_until("b has an address in the agreed range", || async {
|
|
|
|
|
let view = b.plugin.overview(network_id)?;
|
|
|
|
|
let address = view.overlay_address_v4?;
|
|
|
|
|
agreed.contains(address).then_some(view)
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
assert_ne!(view_a.overlay_address_v4, view_b.overlay_address_v4);
|
2026-09-21 13:11:09 +01:00
|
|
|
|
2026-09-21 13:43:01 +01:00
|
|
|
// And each sees the other at the same address it sees for itself.
|
|
|
|
|
let seen_b = wait_until("a sees b's address", || async {
|
|
|
|
|
a.plugin
|
|
|
|
|
.overview(network_id)?
|
|
|
|
|
.peers
|
|
|
|
|
.first()?
|
|
|
|
|
.overlay_address_v4
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
assert_eq!(Some(seen_b), view_b.overlay_address_v4);
|
2026-09-21 13:11:09 +01:00
|
|
|
|
|
|
|
|
a.shutdown().await;
|
|
|
|
|
b.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 13:53:42 +01:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn an_allocated_address_missing_from_the_host_is_reported() {
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-missing-address");
|
|
|
|
|
|
2026-09-21 21:21:55 +01:00
|
|
|
// An interface that says it is on the host and never carries the
|
|
|
|
|
// address: a provisioner that reported a success it did not achieve, or
|
|
|
|
|
// something outside that took the address away. Left unsaid, packets
|
|
|
|
|
// leave with the wrong source and every peer drops them, which looks
|
|
|
|
|
// like a broken network rather than a missing command.
|
|
|
|
|
let a = WgAgent::spawn_pretending_host(&discovery, "ta").await;
|
|
|
|
|
let b = WgAgent::spawn_pretending_host(&discovery, "tb").await;
|
2026-09-21 13:53:42 +01:00
|
|
|
|
|
|
|
|
let mut events = a.agent.subscribe();
|
|
|
|
|
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
b.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
a.wait_for_tunnels(network_id, 1).await;
|
|
|
|
|
|
|
|
|
|
let reason = wait_event(&mut events, |event| match event {
|
|
|
|
|
Event::PluginError { reason, .. } if reason.contains("not on any") => Some(reason.clone()),
|
|
|
|
|
_ => None,
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
let allocated = a
|
|
|
|
|
.plugin
|
|
|
|
|
.overview(network_id)
|
|
|
|
|
.unwrap()
|
|
|
|
|
.overlay_address_v4
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
reason.contains(&allocated.to_string()),
|
|
|
|
|
"unexpected: {reason}"
|
|
|
|
|
);
|
2026-09-21 19:44:21 +01:00
|
|
|
// The agent owns the interface, so it is the agent that notices and the
|
|
|
|
|
// report names the interface the address should have been on.
|
2026-09-21 13:53:42 +01:00
|
|
|
assert!(
|
2026-09-21 19:44:21 +01:00
|
|
|
reason.contains(&a.agent.overlay().unwrap().interface),
|
2026-09-21 14:46:39 +01:00
|
|
|
"must name the interface: {reason}"
|
2026-09-21 13:53:42 +01:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
a.shutdown().await;
|
|
|
|
|
b.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 21:21:55 +01:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn an_interface_that_is_only_in_memory_is_not_reported_as_a_fault() {
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-memory-interface");
|
|
|
|
|
|
|
|
|
|
// `--no-tun` is an arrangement, not a failure: the tunnels run and the
|
|
|
|
|
// packets move between agents, and nothing was ever going to put an
|
|
|
|
|
// address on a host interface that does not exist. Complaining about it
|
|
|
|
|
// sent people looking for something that had removed their address.
|
|
|
|
|
let a = WgAgent::spawn(&discovery, "ta").await;
|
|
|
|
|
let b = WgAgent::spawn(&discovery, "tb").await;
|
|
|
|
|
|
|
|
|
|
let mut events = a.agent.subscribe();
|
|
|
|
|
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
b.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
a.wait_for_tunnels(network_id, 1).await;
|
|
|
|
|
// An address was allocated, so the report had every other reason to fire.
|
|
|
|
|
let address = a.overlay(network_id).await;
|
|
|
|
|
assert!(tsunagi::state::DEFAULT_IPV4_RANGE.contains(address));
|
|
|
|
|
|
|
|
|
|
settle().await;
|
|
|
|
|
let complaint = std::iter::from_fn(|| events.try_recv().ok()).find(
|
|
|
|
|
|event| matches!(event, Event::PluginError { reason, .. } if reason.contains("not on any")),
|
|
|
|
|
);
|
|
|
|
|
assert!(complaint.is_none(), "unexpected: {complaint:?}");
|
|
|
|
|
|
|
|
|
|
// And the interface says plainly what it is, which is what keeps the
|
|
|
|
|
// resolver setting off a host interface of the same name.
|
|
|
|
|
assert!(!a.agent.overlay().unwrap().on_host);
|
|
|
|
|
|
|
|
|
|
a.shutdown().await;
|
|
|
|
|
b.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 21:54:05 +01:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn leaving_a_network_takes_its_protocol_key_with_it() {
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-leave");
|
|
|
|
|
|
|
|
|
|
let agent = WgAgent::spawn(&discovery, "ta").await;
|
|
|
|
|
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
let before = wait_until("the plugin has a key for it", || async {
|
|
|
|
|
Some(agent.plugin.overview(network_id)?.public_key)
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
agent.agent.leave_network(network_id).await.unwrap();
|
|
|
|
|
|
|
|
|
|
// Rejoining is joining, not resuming: the key was this agent's identity
|
|
|
|
|
// in a network it left, and everyone there was told to let the address
|
|
|
|
|
// it held go. Coming back with the same key would claim an identity the
|
|
|
|
|
// network has already released.
|
|
|
|
|
agent.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
let after = wait_until("the plugin has a key again", || async {
|
|
|
|
|
Some(agent.plugin.overview(network_id)?.public_key)
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
assert_ne!(before, after, "a fresh key, not the released one");
|
|
|
|
|
|
|
|
|
|
agent.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 13:43:01 +01:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn an_address_is_kept_across_a_restart() {
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-address-persists");
|
|
|
|
|
|
|
|
|
|
let peer = WgAgent::spawn(&discovery, "tp").await;
|
|
|
|
|
let subject = WgAgent::spawn(&discovery, "ts").await;
|
|
|
|
|
|
|
|
|
|
let network_id = peer.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
subject.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
peer.wait_for_tunnels(network_id, 1).await;
|
|
|
|
|
|
|
|
|
|
let before = wait_until("the subject has an address", || async {
|
|
|
|
|
subject.plugin.overview(network_id)?.overlay_address_v4
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
// The peer agrees about it.
|
|
|
|
|
let seen_before = wait_until("the peer sees it", || async {
|
|
|
|
|
peer.plugin
|
|
|
|
|
.overview(network_id)?
|
|
|
|
|
.peers
|
|
|
|
|
.first()?
|
|
|
|
|
.overlay_address_v4
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
assert_eq!(seen_before, before);
|
|
|
|
|
|
|
|
|
|
// Go away, come back. The address is a signed record, not a derivation
|
|
|
|
|
// and not a session fact, so it survives.
|
|
|
|
|
let dir = subject.shutdown().await;
|
|
|
|
|
let (agent, plugin, _tuns) = WgAgent::open(dir.path(), &discovery, "ts", |config| config).await;
|
|
|
|
|
|
|
|
|
|
let after = wait_until("the restarted agent has an address", || async {
|
|
|
|
|
plugin.overview(network_id)?.overlay_address_v4
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
assert_eq!(
|
|
|
|
|
after, before,
|
|
|
|
|
"a returning participant must reclaim the address it signed for"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// And the peer still agrees, without having had to do anything.
|
|
|
|
|
let seen_after = wait_until("the peer still agrees", || async {
|
|
|
|
|
let seen = peer
|
|
|
|
|
.plugin
|
|
|
|
|
.overview(network_id)?
|
|
|
|
|
.peers
|
|
|
|
|
.first()?
|
|
|
|
|
.overlay_address_v4?;
|
|
|
|
|
(seen == after).then_some(seen)
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
assert_eq!(seen_after, after);
|
|
|
|
|
|
|
|
|
|
agent.shutdown().await;
|
|
|
|
|
peer.agent.shutdown().await;
|
|
|
|
|
drop(agent);
|
|
|
|
|
drop(dir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn three_members_get_three_different_addresses() {
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-allocation");
|
|
|
|
|
|
|
|
|
|
let a = WgAgent::spawn(&discovery, "ta").await;
|
|
|
|
|
let b = WgAgent::spawn(&discovery, "tb").await;
|
|
|
|
|
let c = WgAgent::spawn(&discovery, "tc").await;
|
|
|
|
|
|
|
|
|
|
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
b.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
c.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
|
|
|
|
|
for agent in [&a, &b, &c] {
|
|
|
|
|
agent.wait_for_tunnels(network_id, 2).await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Everybody ends up knowing all three addresses, and they are distinct.
|
|
|
|
|
let addresses = wait_until("everyone agrees on three addresses", || async {
|
|
|
|
|
let mut all = std::collections::BTreeSet::new();
|
|
|
|
|
for agent in [&a, &b, &c] {
|
|
|
|
|
let view = agent.plugin.overview(network_id)?;
|
|
|
|
|
all.insert(view.overlay_address_v4?);
|
|
|
|
|
for peer in &view.peers {
|
|
|
|
|
all.insert(peer.overlay_address_v4?);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
(all.len() == 3).then_some(all)
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
let default_range = tsunagi::state::DEFAULT_IPV4_RANGE;
|
|
|
|
|
for address in &addresses {
|
|
|
|
|
assert!(
|
|
|
|
|
default_range.contains(*address),
|
|
|
|
|
"{address} outside the range"
|
|
|
|
|
);
|
|
|
|
|
assert_ne!(address.octets()[3], 0);
|
|
|
|
|
assert_ne!(address.octets()[3], 255);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a.shutdown().await;
|
|
|
|
|
b.shutdown().await;
|
|
|
|
|
c.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn packets_for_an_unknown_address_are_counted_not_broadcast() {
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-unroutable");
|
|
|
|
|
|
|
|
|
|
let a = WgAgent::spawn(&discovery, "ta").await;
|
|
|
|
|
let b = WgAgent::spawn(&discovery, "tb").await;
|
|
|
|
|
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
b.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
a.wait_for_tunnels(network_id, 1).await;
|
|
|
|
|
|
|
|
|
|
let addr_a = a.overlay(network_id).await;
|
|
|
|
|
let tun_a = a.tun(network_id).await;
|
|
|
|
|
let tun_b = b.tun(network_id).await;
|
|
|
|
|
|
|
|
|
|
// Nobody owns this address, so it must not be sent to anybody.
|
2026-09-21 18:40:49 +01:00
|
|
|
let nowhere: Ipv4Addr = "192.0.2.111".parse().unwrap();
|
|
|
|
|
tun_a.push_from_os(ipv4_packet(addr_a, nowhere, b"lost"));
|
2026-09-21 11:55:20 +01:00
|
|
|
|
|
|
|
|
wait_until("the packet is counted as unroutable", || async {
|
|
|
|
|
let view = a.plugin.overview(network_id)?;
|
2026-09-21 19:20:30 +01:00
|
|
|
let _ = view;
|
|
|
|
|
(a.agent.overlay()?.counters.unroutable >= 1).then_some(())
|
2026-09-21 11:55:20 +01:00
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
settle().await;
|
|
|
|
|
assert!(
|
|
|
|
|
tokio::time::timeout(Duration::from_millis(200), tun_b.pop_to_os())
|
|
|
|
|
.await
|
|
|
|
|
.is_err(),
|
|
|
|
|
"an unroutable packet must not reach another member"
|
2026-09-21 11:07:31 +01:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
a.shutdown().await;
|
|
|
|
|
b.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
2026-09-21 11:55:20 +01:00
|
|
|
async fn a_mesh_of_three_establishes_every_tunnel() {
|
2026-09-21 11:07:31 +01:00
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-mesh");
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
let a = WgAgent::spawn(&discovery, "ta").await;
|
|
|
|
|
let b = WgAgent::spawn(&discovery, "tb").await;
|
|
|
|
|
let c = WgAgent::spawn(&discovery, "tc").await;
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
b.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
c.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
|
|
|
|
|
for agent in [&a, &b, &c] {
|
|
|
|
|
wait_for_peers(&agent.agent, network_id, 2).await;
|
2026-09-21 11:55:20 +01:00
|
|
|
// N - 1 tunnels, all handshaken.
|
|
|
|
|
agent.wait_for_tunnels(network_id, 2).await;
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
// Everyone agrees on the subnet and nobody configured themselves.
|
|
|
|
|
let mut addresses = Vec::new();
|
2026-09-21 11:07:31 +01:00
|
|
|
for agent in [&a, &b, &c] {
|
2026-09-21 11:55:20 +01:00
|
|
|
let view = agent.plugin.overview(network_id).unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
view.peers
|
|
|
|
|
.iter()
|
|
|
|
|
.all(|peer| peer.public_key != view.public_key)
|
|
|
|
|
);
|
2026-09-21 18:40:49 +01:00
|
|
|
addresses.push(agent.overlay(network_id).await);
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
2026-09-21 11:55:20 +01:00
|
|
|
addresses.sort();
|
|
|
|
|
addresses.dedup();
|
|
|
|
|
assert_eq!(addresses.len(), 3, "every member has its own address");
|
|
|
|
|
|
|
|
|
|
// A packet from A reaches C directly, not via B.
|
|
|
|
|
let addr_a = a.overlay(network_id).await;
|
|
|
|
|
let addr_c = c.overlay(network_id).await;
|
|
|
|
|
a.tun(network_id)
|
|
|
|
|
.await
|
2026-09-21 18:40:49 +01:00
|
|
|
.push_from_os(ipv4_packet(addr_a, addr_c, b"a to c"));
|
2026-09-21 19:55:30 +01:00
|
|
|
let received = tokio::time::timeout(
|
|
|
|
|
tsunagi::testing::DEADLINE,
|
|
|
|
|
c.tun(network_id).await.pop_to_os(),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("the packet should arrive")
|
|
|
|
|
.unwrap();
|
2026-09-21 18:40:49 +01:00
|
|
|
assert_eq!(&received[20..], b"a to c");
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
a.shutdown().await;
|
|
|
|
|
b.shutdown().await;
|
|
|
|
|
c.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
2026-09-21 11:55:20 +01:00
|
|
|
async fn a_departing_peer_loses_its_tunnel() {
|
2026-09-21 11:07:31 +01:00
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-departure");
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
let stayer = WgAgent::spawn(&discovery, "ta").await;
|
|
|
|
|
let leaver = WgAgent::spawn(&discovery, "tb").await;
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
let network_id = stayer.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
leaver.agent.join_network(&name, &secret).await.unwrap();
|
2026-09-21 11:55:20 +01:00
|
|
|
stayer.wait_for_tunnels(network_id, 1).await;
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
let leaver_id = leaver.endpoint_id();
|
|
|
|
|
let mut events = stayer.agent.subscribe();
|
|
|
|
|
leaver.shutdown().await;
|
|
|
|
|
|
|
|
|
|
wait_event(&mut events, |event| match event {
|
|
|
|
|
Event::PeerDisconnected { peer, .. } if *peer == leaver_id => Some(()),
|
|
|
|
|
_ => None,
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
wait_until("the tunnel is removed", || async {
|
|
|
|
|
let view = stayer.plugin.overview(network_id)?;
|
|
|
|
|
view.peers.is_empty().then_some(())
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
// The interface itself stays; only the peer went.
|
|
|
|
|
assert!(stayer.plugin.overview(network_id).is_some());
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
stayer.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
2026-09-21 19:20:30 +01:00
|
|
|
async fn two_networks_share_one_interface_with_keys_and_ranges_of_their_own() {
|
|
|
|
|
// One agent, one interface — so two networks on it must use different
|
|
|
|
|
// ranges, or an address would belong to both. Each network's range is
|
|
|
|
|
// settled by whoever got there first, and the agent adopts what it
|
|
|
|
|
// finds; see the routing table's own tests for the refusal when they
|
|
|
|
|
// overlap.
|
2026-09-21 11:07:31 +01:00
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name_a, secret_a) = network("wg-left");
|
|
|
|
|
let (name_b, secret_b) = network("wg-right");
|
2026-09-21 19:20:30 +01:00
|
|
|
let beta_range = Some("10.99.0.0/16".parse::<Ipv4Range>().unwrap());
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
let hub = WgAgent::spawn(&discovery, "th").await;
|
|
|
|
|
let left = WgAgent::spawn(&discovery, "tl").await;
|
2026-09-21 19:20:30 +01:00
|
|
|
let right = WgAgent::spawn_range(&discovery, "tr", beta_range).await;
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 19:20:30 +01:00
|
|
|
// The other members settle each range before the hub joins, so it has
|
|
|
|
|
// something to adopt rather than a default to collide with.
|
|
|
|
|
let alpha = left.agent.join_network(&name_a, &secret_a).await.unwrap();
|
|
|
|
|
let beta = right.agent.join_network(&name_b, &secret_b).await.unwrap();
|
|
|
|
|
left.overlay(alpha).await;
|
|
|
|
|
right.overlay(beta).await;
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 19:20:30 +01:00
|
|
|
hub.agent.join_network(&name_a, &secret_a).await.unwrap();
|
|
|
|
|
hub.agent.join_network(&name_b, &secret_b).await.unwrap();
|
2026-09-21 11:55:20 +01:00
|
|
|
hub.wait_for_tunnels(alpha, 1).await;
|
|
|
|
|
hub.wait_for_tunnels(beta, 1).await;
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
assert_ne!(
|
2026-09-21 19:20:30 +01:00
|
|
|
hub.plugin.overview(alpha).unwrap().public_key,
|
|
|
|
|
hub.plugin.overview(beta).unwrap().public_key,
|
2026-09-21 11:55:20 +01:00
|
|
|
"one WireGuard identity per network, not one per host"
|
2026-09-21 11:07:31 +01:00
|
|
|
);
|
2026-09-21 19:20:30 +01:00
|
|
|
assert_eq!(
|
|
|
|
|
hub.tuns.devices().len(),
|
|
|
|
|
1,
|
|
|
|
|
"one agent has one interface, whatever it is a member of"
|
|
|
|
|
);
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 19:20:30 +01:00
|
|
|
// Each network's address comes from its own range.
|
|
|
|
|
let hub_alpha = wait_until("the hub settles into alpha's range", || async {
|
|
|
|
|
let address = hub.plugin.overview(alpha)?.overlay_address_v4?;
|
|
|
|
|
tsunagi::state::DEFAULT_IPV4_RANGE
|
|
|
|
|
.contains(address)
|
|
|
|
|
.then_some(address)
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
let hub_beta = wait_until("the hub settles into beta's range", || async {
|
|
|
|
|
let address = hub.plugin.overview(beta)?.overlay_address_v4?;
|
|
|
|
|
beta_range?.contains(address).then_some(address)
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
assert_ne!(hub_alpha, hub_beta);
|
|
|
|
|
|
|
|
|
|
// Traffic in one overlay never surfaces in the other, though both cross
|
|
|
|
|
// the same interface.
|
2026-09-21 11:55:20 +01:00
|
|
|
let left_addr = left.overlay(alpha).await;
|
|
|
|
|
hub.tun(alpha)
|
|
|
|
|
.await
|
2026-09-21 18:40:49 +01:00
|
|
|
.push_from_os(ipv4_packet(hub_alpha, left_addr, b"alpha only"));
|
2026-09-21 19:55:30 +01:00
|
|
|
let seen = tokio::time::timeout(
|
|
|
|
|
tsunagi::testing::DEADLINE,
|
|
|
|
|
left.tun(alpha).await.pop_to_os(),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("the packet should arrive")
|
|
|
|
|
.unwrap();
|
2026-09-21 18:40:49 +01:00
|
|
|
assert_eq!(&seen[20..], b"alpha only");
|
2026-09-21 11:55:20 +01:00
|
|
|
assert!(
|
|
|
|
|
tokio::time::timeout(
|
|
|
|
|
Duration::from_millis(200),
|
|
|
|
|
right.tun(beta).await.pop_to_os()
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.is_err(),
|
|
|
|
|
"the other overlay must see nothing"
|
|
|
|
|
);
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
hub.shutdown().await;
|
|
|
|
|
left.shutdown().await;
|
|
|
|
|
right.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn restarting_keeps_the_wireguard_identity_and_overlay_address() {
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-restart");
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
let peer = WgAgent::spawn(&discovery, "tp").await;
|
|
|
|
|
let subject = WgAgent::spawn(&discovery, "ts").await;
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
let network_id = peer.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
subject.agent.join_network(&name, &secret).await.unwrap();
|
2026-09-21 11:55:20 +01:00
|
|
|
peer.wait_for_tunnels(network_id, 1).await;
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
let before = subject.plugin.overview(network_id).unwrap();
|
|
|
|
|
let dir = subject.shutdown().await;
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
let (agent, plugin, _tuns) = WgAgent::open(dir.path(), &discovery, "ts", |config| config).await;
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
let after = wait_until("the restarted plugin is ready", || async {
|
|
|
|
|
plugin.overview(network_id)
|
|
|
|
|
})
|
|
|
|
|
.await;
|
2026-09-21 11:55:20 +01:00
|
|
|
assert_eq!(after.public_key, before.public_key);
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
// The tunnel comes back on its own.
|
|
|
|
|
wait_until("the tunnel is re-established", || async {
|
|
|
|
|
let view = plugin.overview(network_id)?;
|
|
|
|
|
(view.established_peers() == 1).then_some(())
|
2026-09-21 11:07:31 +01:00
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
agent.shutdown().await;
|
|
|
|
|
peer.shutdown().await;
|
|
|
|
|
drop(agent);
|
|
|
|
|
drop(dir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn shutdown_removes_every_interface_the_plugin_created() {
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name_a, secret_a) = network("wg-teardown-a");
|
|
|
|
|
let (name_b, secret_b) = network("wg-teardown-b");
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
let agent = WgAgent::spawn(&discovery, "ta").await;
|
2026-09-21 11:07:31 +01:00
|
|
|
let alpha = agent.agent.join_network(&name_a, &secret_a).await.unwrap();
|
|
|
|
|
let beta = agent.agent.join_network(&name_b, &secret_b).await.unwrap();
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
agent.tun(alpha).await;
|
|
|
|
|
agent.tun(beta).await;
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
agent.agent.shutdown().await;
|
2026-09-21 11:55:20 +01:00
|
|
|
assert!(agent.plugin.overview(alpha).is_none());
|
|
|
|
|
assert!(agent.plugin.overview(beta).is_none());
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
2026-09-21 11:55:20 +01:00
|
|
|
async fn the_core_carries_the_payload_without_interpreting_it() {
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-opaque");
|
|
|
|
|
|
|
|
|
|
let a = WgAgent::spawn(&discovery, "ta").await;
|
|
|
|
|
let b = WgAgent::spawn(&discovery, "tb").await;
|
|
|
|
|
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
b.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
wait_for_peers(&a.agent, network_id, 1).await;
|
|
|
|
|
|
|
|
|
|
let capability = wait_until("the peer's capability arrived", || async {
|
|
|
|
|
let status: NetworkStatus = a.agent.network_status(network_id).await.ok()?;
|
|
|
|
|
status
|
|
|
|
|
.peers
|
|
|
|
|
.first()
|
|
|
|
|
.and_then(|peer| peer.capabilities.first().cloned())
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
assert_eq!(capability.protocol, WIREGUARD_PROTOCOL);
|
|
|
|
|
|
|
|
|
|
let view_b = b.plugin.overview(network_id).unwrap();
|
2026-09-21 13:43:01 +01:00
|
|
|
let expected = WgAnnouncement::new(network_id, &view_b.public_key)
|
2026-09-21 11:55:20 +01:00
|
|
|
.encode()
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(capability.data, expected);
|
|
|
|
|
assert!(capability.data.len() < tsunagi::Limits::default().max_capability_data_len);
|
|
|
|
|
|
|
|
|
|
a.shutdown().await;
|
|
|
|
|
b.shutdown().await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
2026-09-21 18:40:49 +01:00
|
|
|
async fn an_announcement_for_another_network_never_reaches_a_tunnel() {
|
2026-09-21 11:07:31 +01:00
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let name = NetworkName::new("wg-hijack").unwrap();
|
|
|
|
|
let secret = NetworkSecret::generate();
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
let victim = WgAgent::spawn(&discovery, "tv").await;
|
2026-09-21 11:07:31 +01:00
|
|
|
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
|
2026-09-21 18:40:49 +01:00
|
|
|
// Addresses are not in an announcement any more, so there is no address
|
|
|
|
|
// to forge. What is left to lie about is which network the key is for —
|
|
|
|
|
// and a capability is scoped to the session it arrived on, so claiming
|
|
|
|
|
// another network's is the shape a confused or hostile member takes.
|
2026-09-21 11:07:31 +01:00
|
|
|
let attacker_key = WgSecretKey::generate().public();
|
2026-09-21 18:40:49 +01:00
|
|
|
let elsewhere = tsunagi::identity::NetworkKeys::derive(
|
|
|
|
|
&NetworkName::new("somewhere-else").unwrap(),
|
|
|
|
|
&NetworkSecret::generate(),
|
|
|
|
|
)
|
|
|
|
|
.network_id();
|
|
|
|
|
let forged = WgAnnouncement::new(elsewhere, &attacker_key);
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
let forger = Arc::new(ForgingPlugin {
|
|
|
|
|
payload: std::sync::Mutex::new(Some(forged.encode().unwrap())),
|
|
|
|
|
});
|
|
|
|
|
let attacker_dir = TempDir::new().unwrap();
|
|
|
|
|
let attacker = Agent::spawn(
|
|
|
|
|
config_with(attacker_dir.path(), &discovery)
|
|
|
|
|
.with_plugin(forger.clone() as Arc<dyn IpPlugin>),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let mut events = victim.agent.subscribe();
|
|
|
|
|
attacker.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
wait_for_peers(&victim.agent, network_id, 1).await;
|
|
|
|
|
|
2026-09-21 18:40:49 +01:00
|
|
|
// Filtered on the reason: this agent reports other things too, and the
|
|
|
|
|
// first plugin error to arrive is not necessarily this one.
|
2026-09-21 11:07:31 +01:00
|
|
|
let reason = wait_event(&mut events, |event| match event {
|
|
|
|
|
Event::PluginError {
|
|
|
|
|
protocol, reason, ..
|
2026-09-21 18:40:49 +01:00
|
|
|
} if protocol == WIREGUARD_PROTOCOL && reason.contains("different network") => {
|
|
|
|
|
Some(reason.clone())
|
|
|
|
|
}
|
2026-09-21 11:07:31 +01:00
|
|
|
_ => None,
|
|
|
|
|
})
|
|
|
|
|
.await;
|
2026-09-21 18:40:49 +01:00
|
|
|
assert!(reason.contains("different network"), "unexpected: {reason}");
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
settle().await;
|
2026-09-21 11:55:20 +01:00
|
|
|
let view = victim.plugin.overview(network_id).unwrap();
|
2026-09-21 11:07:31 +01:00
|
|
|
assert!(
|
2026-09-21 11:55:20 +01:00
|
|
|
view.peers
|
|
|
|
|
.iter()
|
|
|
|
|
.all(|peer| peer.public_key != attacker_key),
|
|
|
|
|
"a rejected announcement must never become a tunnel"
|
2026-09-21 11:07:31 +01:00
|
|
|
);
|
|
|
|
|
attacker.shutdown().await;
|
|
|
|
|
victim.shutdown().await;
|
|
|
|
|
drop(attacker_dir);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 19:34:38 +01:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn a_peer_at_another_protocol_version_gets_no_data_plane_and_keeps_the_control_plane() {
|
|
|
|
|
// Both sides must have the protocol at the same version. There is no
|
|
|
|
|
// middle ground to negotiate: either the words mean the same thing at
|
|
|
|
|
// both ends or they do not.
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-version");
|
|
|
|
|
|
|
|
|
|
let here = WgAgent::spawn(&discovery, "tvh").await;
|
|
|
|
|
let network_id = here.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
|
|
|
|
|
let ahead = Arc::new(FromTheFuture);
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let other = Agent::spawn(
|
|
|
|
|
config_with(dir.path(), &discovery).with_plugin(ahead.clone() as Arc<dyn IpPlugin>),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let mut events = here.agent.subscribe();
|
|
|
|
|
other.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
wait_for_peers(&here.agent, network_id, 1).await;
|
|
|
|
|
|
|
|
|
|
let reason = wait_event(&mut events, |event| match event {
|
|
|
|
|
Event::PluginError { reason, .. } if reason.contains("version") => Some(reason.clone()),
|
|
|
|
|
_ => None,
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
assert!(
|
|
|
|
|
reason.contains("control plane is unaffected"),
|
|
|
|
|
"the message should say what still works: {reason}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// No tunnel, and no attempt at one.
|
|
|
|
|
settle().await;
|
|
|
|
|
assert!(
|
|
|
|
|
here.plugin.overview(network_id).unwrap().peers.is_empty(),
|
|
|
|
|
"a version that cannot match must not become a tunnel"
|
|
|
|
|
);
|
|
|
|
|
let status = here.agent.network_status(network_id).await.unwrap();
|
|
|
|
|
assert_eq!(status.peers.len(), 1, "the session is up all the same");
|
|
|
|
|
assert!(
|
|
|
|
|
status.peers[0].protocols.is_empty(),
|
|
|
|
|
"nothing was agreed with it: {:?}",
|
|
|
|
|
status.peers[0].protocols
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// And the control plane carries a message to it regardless.
|
|
|
|
|
assert_eq!(
|
|
|
|
|
here.agent
|
|
|
|
|
.broadcast(
|
|
|
|
|
network_id,
|
|
|
|
|
tsunagi::proto::ControlMessage::Ping {
|
|
|
|
|
seq: 1,
|
|
|
|
|
payload: b"still talking".to_vec(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap(),
|
|
|
|
|
1
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
other.shutdown().await;
|
|
|
|
|
here.shutdown().await;
|
|
|
|
|
drop(dir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn agreement_turns_on_the_protocol_version_and_nothing_else() {
|
|
|
|
|
// A different build is not a different protocol. What is compared is the
|
|
|
|
|
// wire version and the name; everything else about a peer — its release,
|
|
|
|
|
// and whatever opaque payload its announcement carries — has no say.
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-same-wire");
|
|
|
|
|
|
|
|
|
|
let here = WgAgent::spawn(&discovery, "tsw").await;
|
|
|
|
|
let network_id = here.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
|
|
|
|
|
// Announces the right protocol at the right version, with a payload
|
|
|
|
|
// this build has never seen.
|
|
|
|
|
let stranger = Arc::new(ForgingPlugin {
|
|
|
|
|
payload: std::sync::Mutex::new(Some(b"from some other release".to_vec())),
|
|
|
|
|
});
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let other = Agent::spawn(
|
|
|
|
|
config_with(dir.path(), &discovery).with_plugin(stranger.clone() as Arc<dyn IpPlugin>),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
other.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
wait_for_peers(&here.agent, network_id, 1).await;
|
|
|
|
|
|
|
|
|
|
// Agreed, because the wire matches. The payload is rejected by the
|
|
|
|
|
// protocol afterwards, which is a separate matter and says nothing about
|
|
|
|
|
// whether the two agreed to talk.
|
|
|
|
|
wait_until("the protocol is agreed with it", || async {
|
|
|
|
|
let status = here.agent.network_status(network_id).await.ok()?;
|
|
|
|
|
status
|
|
|
|
|
.peers
|
|
|
|
|
.first()?
|
|
|
|
|
.protocols
|
|
|
|
|
.contains(&WIREGUARD_PROTOCOL.to_string())
|
|
|
|
|
.then_some(())
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
other.shutdown().await;
|
|
|
|
|
here.shutdown().await;
|
|
|
|
|
drop(dir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A plugin claiming the same protocol at a version this build does not speak.
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
struct FromTheFuture;
|
|
|
|
|
|
|
|
|
|
impl IpPlugin for FromTheFuture {
|
|
|
|
|
fn protocol_id(&self) -> &str {
|
|
|
|
|
WIREGUARD_PROTOCOL
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn protocol_version(&self) -> u16 {
|
2026-09-21 19:55:30 +01:00
|
|
|
tsunagi_wg_quic::ANNOUNCEMENT_VERSION + 1
|
2026-09-21 19:34:38 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn local_capability(
|
|
|
|
|
&self,
|
|
|
|
|
_network: NetworkId,
|
|
|
|
|
) -> Result<Option<tsunagi::dataplane::PluginCapability>, tsunagi::dataplane::PluginError> {
|
|
|
|
|
Ok(Some(tsunagi::dataplane::PluginCapability {
|
|
|
|
|
protocol: WIREGUARD_PROTOCOL.to_string(),
|
|
|
|
|
version: self.protocol_version(),
|
|
|
|
|
enabled: true,
|
|
|
|
|
data: Vec::new(),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn on_peer_capability(
|
|
|
|
|
&self,
|
|
|
|
|
_network: NetworkId,
|
|
|
|
|
_peer: EndpointId,
|
|
|
|
|
_capability: &tsunagi::dataplane::PluginCapability,
|
|
|
|
|
) -> Result<(), tsunagi::dataplane::PluginError> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn on_peer_gone(&self, _network: NetworkId, _peer: EndpointId) {}
|
|
|
|
|
fn on_network_deactivated(&self, _network: NetworkId) {}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
/// A plugin that announces whatever bytes it is told to, under the WireGuard
|
|
|
|
|
/// protocol id. Used to test what a hostile member can do.
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
struct ForgingPlugin {
|
|
|
|
|
payload: std::sync::Mutex<Option<Vec<u8>>>,
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
impl IpPlugin for ForgingPlugin {
|
|
|
|
|
fn protocol_id(&self) -> &str {
|
|
|
|
|
WIREGUARD_PROTOCOL
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
|
|
|
|
|
2026-09-21 19:34:38 +01:00
|
|
|
/// The same version, so the announcement is looked at rather than
|
|
|
|
|
/// dismissed for the wrong reason.
|
|
|
|
|
fn protocol_version(&self) -> u16 {
|
2026-09-21 19:55:30 +01:00
|
|
|
tsunagi_wg_quic::ANNOUNCEMENT_VERSION
|
2026-09-21 19:34:38 +01:00
|
|
|
}
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
fn local_capability(
|
|
|
|
|
&self,
|
|
|
|
|
_network: NetworkId,
|
|
|
|
|
) -> Result<Option<tsunagi::dataplane::PluginCapability>, tsunagi::dataplane::PluginError> {
|
|
|
|
|
let payload = match self.payload.lock() {
|
|
|
|
|
Ok(guard) => guard.clone(),
|
|
|
|
|
Err(poisoned) => poisoned.into_inner().clone(),
|
|
|
|
|
};
|
|
|
|
|
Ok(payload.map(|data| tsunagi::dataplane::PluginCapability {
|
|
|
|
|
protocol: WIREGUARD_PROTOCOL.to_string(),
|
2026-09-21 19:34:38 +01:00
|
|
|
// The version it actually speaks, so the payload is examined
|
|
|
|
|
// rather than set aside for the wrong reason.
|
2026-09-21 19:55:30 +01:00
|
|
|
version: tsunagi_wg_quic::ANNOUNCEMENT_VERSION,
|
2026-09-21 11:55:20 +01:00
|
|
|
enabled: true,
|
|
|
|
|
data,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn on_peer_capability(
|
|
|
|
|
&self,
|
|
|
|
|
_network: NetworkId,
|
|
|
|
|
_peer: EndpointId,
|
|
|
|
|
_capability: &tsunagi::dataplane::PluginCapability,
|
|
|
|
|
) -> Result<(), tsunagi::dataplane::PluginError> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn on_peer_gone(&self, _network: NetworkId, _peer: EndpointId) {}
|
|
|
|
|
fn on_network_deactivated(&self, _network: NetworkId) {}
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
|
|
|
|
|
2026-09-21 12:41:57 +01:00
|
|
|
#[tokio::test]
|
2026-09-21 18:40:49 +01:00
|
|
|
async fn an_mtu_below_what_ipv4_guarantees_is_refused() {
|
2026-09-21 19:55:30 +01:00
|
|
|
use tsunagi_wg_quic::{DEFAULT_MTU, MIN_MTU, WIREGUARD_OVERHEAD};
|
2026-09-21 12:41:57 +01:00
|
|
|
|
2026-09-21 18:40:49 +01:00
|
|
|
// 576 bytes is what every IPv4 host must be able to reassemble, so
|
|
|
|
|
// nothing below it is worth offering. The floor used to be 1280 for
|
|
|
|
|
// IPv6's sake; the overlay is IPv4 now and a relayed path with small
|
|
|
|
|
// datagrams can be matched instead of warned about.
|
2026-09-21 12:41:57 +01:00
|
|
|
let dir = TempDir::new().unwrap();
|
2026-09-21 19:20:30 +01:00
|
|
|
let result =
|
|
|
|
|
WireguardPlugin::open(WireguardConfig::new(dir.path()).with_mtu(MIN_MTU - 1)).await;
|
2026-09-21 12:41:57 +01:00
|
|
|
match result {
|
|
|
|
|
Err(err) => {
|
|
|
|
|
let text = err.to_string();
|
2026-09-21 18:40:49 +01:00
|
|
|
assert!(text.contains("576"), "unexpected message: {text}");
|
2026-09-21 12:41:57 +01:00
|
|
|
}
|
2026-09-21 18:40:49 +01:00
|
|
|
Ok(_) => panic!("an MTU below what IPv4 guarantees must be refused"),
|
2026-09-21 12:41:57 +01:00
|
|
|
}
|
|
|
|
|
|
2026-09-21 18:40:49 +01:00
|
|
|
// The default leaves room below it now, which is the point of lowering
|
|
|
|
|
// the floor, and a link has to carry it plus WireGuard's own overhead.
|
|
|
|
|
const { assert!(DEFAULT_MTU > MIN_MTU) };
|
2026-09-21 12:41:57 +01:00
|
|
|
assert_eq!(WIREGUARD_OVERHEAD, 32);
|
|
|
|
|
assert!(
|
2026-09-21 19:20:30 +01:00
|
|
|
WireguardPlugin::open(WireguardConfig::new(dir.path().join("ok")))
|
|
|
|
|
.await
|
|
|
|
|
.is_ok()
|
2026-09-21 12:41:57 +01:00
|
|
|
);
|
|
|
|
|
}
|
2026-09-22 01:32:43 +01:00
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn a_peer_with_no_direct_path_is_reached_through_one_that_has_both() {
|
|
|
|
|
// A and B can each reach C and not each other. Without a way through
|
|
|
|
|
// the middle they are lost to one another while sitting in the same
|
|
|
|
|
// mesh; with one, C carries their datagrams without being able to read
|
|
|
|
|
// a byte of them — the WireGuard tunnel is still end to end.
|
|
|
|
|
let discovery = SharedMemoryDiscovery::new();
|
|
|
|
|
let (name, secret) = network("wg-relay");
|
|
|
|
|
|
|
|
|
|
let a_blocks = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
|
|
|
|
|
let b_blocks = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
|
|
|
|
|
let a = WgAgent::spawn_cut_off(&discovery, "tra", Arc::clone(&a_blocks)).await;
|
|
|
|
|
let b = WgAgent::spawn_cut_off(&discovery, "trb", Arc::clone(&b_blocks)).await;
|
|
|
|
|
let middle = WgAgent::spawn(&discovery, "trc").await;
|
|
|
|
|
a_blocks.lock().unwrap().insert(b.endpoint_id());
|
|
|
|
|
b_blocks.lock().unwrap().insert(a.endpoint_id());
|
|
|
|
|
|
|
|
|
|
let network_id = middle.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
a.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
b.agent.join_network(&name, &secret).await.unwrap();
|
|
|
|
|
|
|
|
|
|
// All three are members and all three talk: only the data path
|
|
|
|
|
// between A and B is missing.
|
|
|
|
|
wait_for_peers(&a.agent, network_id, 2).await;
|
|
|
|
|
wait_for_peers(&b.agent, network_id, 2).await;
|
|
|
|
|
middle.wait_for_tunnels(network_id, 2).await;
|
|
|
|
|
|
|
|
|
|
let a_addr = a.overlay(network_id).await;
|
|
|
|
|
let b_addr = b.overlay(network_id).await;
|
|
|
|
|
assert_ne!(a_addr, b_addr);
|
|
|
|
|
|
|
|
|
|
// A's tunnel to B comes up through C.
|
|
|
|
|
wait_until("a's tunnel to b is established", || async {
|
|
|
|
|
let view = a.plugin.overview(network_id)?;
|
|
|
|
|
view.peers
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|peer| peer.endpoint_id == b.endpoint_id())?
|
|
|
|
|
.tunnel
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|tunnel| tunnel.health.since_handshake)?
|
|
|
|
|
.map(|_| ())
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
let path = a
|
|
|
|
|
.plugin
|
|
|
|
|
.overview(network_id)
|
|
|
|
|
.unwrap()
|
|
|
|
|
.peers
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|peer| peer.endpoint_id == b.endpoint_id())
|
|
|
|
|
.and_then(|peer| peer.tunnel.as_ref().map(|tunnel| tunnel.path.clone()))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
assert!(
|
|
|
|
|
path.contains("via"),
|
|
|
|
|
"the path should say it goes through somebody: {path}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// And a real packet crosses: A's interface to B's interface, through C.
|
|
|
|
|
a.tun(network_id)
|
|
|
|
|
.await
|
|
|
|
|
.push_from_os(ipv4_packet(a_addr, b_addr, b"through the middle"));
|
|
|
|
|
let seen = tokio::time::timeout(
|
|
|
|
|
tsunagi::testing::DEADLINE,
|
|
|
|
|
b.tun(network_id).await.pop_to_os(),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("the packet should arrive")
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(&seen[20..], b"through the middle");
|
|
|
|
|
|
|
|
|
|
a.shutdown().await;
|
|
|
|
|
b.shutdown().await;
|
|
|
|
|
middle.shutdown().await;
|
|
|
|
|
}
|