When a peer belongs to multiple overlay networks served by one agent (e.g.
a VM participating in both 'hexor' and 'ultradesu'), packets received on
the shared WireGuard UDP socket could not be differentiated by source IP/port
alone. Previously, `WireguardTransport::accept_loop` picked the first
matching link found in iteration order, starving other networks for that peer
and causing rekey timeouts (`WARN HANDSHAKE(REKEY_TIMEOUT)`,
`ERROR CONNECTION_EXPIRED(REKEY_ATTEMPT_TIME)`) and packet loss on the
starved network.
Additionally:
- When a peer had multiple candidate endpoints (such as LAN, VM bridge, and
Tailscale CGNAT addresses), `WireguardLink` sent handshake initiations
only to `addrs[0]`. If the first address was unreachable from the remote
side (e.g. host's Tailscale IP unreachable from inside a local VM), the
handshake never completed.
- In `build_report`, peers known to the `wg-quic` plugin were added with
empty tunnels even if an active `wg` (pure WireGuard) tunnel existed,
causing `tsunagi status` to falsely report 'no WireGuard handshake yet'
for connected and pingable peers.
- `local_ip_candidates` previously included IPv6 addresses which caused
OS error 22 (EINVAL) when passed to the IPv4-bound UDP socket.
Key fixes:
- In `crates/tsunagi-wireguard/src/transport.rs`:
- Store `peer_networks` as `HashSet<NetworkId>` per peer.
- In `WireguardLink`, maintain a list of candidate addresses and a
`confirmed` flag. Before a handshake response confirms the active path,
transmit datagrams (handshake initiations) to all candidate addresses.
Upon receiving an inbound datagram, lock onto the confirmed address.
- In `accept_loop`, deliver inbound datagrams to all active links
associated with the sender's endpoint ID across all networks. Boringtun's
cryptographic index and AEAD verification naturally demultiplexes and
accepts the packet in the correct network while discarding it in others.
- Filter candidate addresses to IPv4 to match the bound socket family.
- In `crates/tsunagi-wireguard/src/plugin.rs`:
- Only collect IPv4 interfaces in `local_ip_candidates`.
- In `crates/tsunagi-cli/src/main.rs`:
- Refactor `build_report` to evaluate all protocols per peer and prioritize
whichever protocol has an active tunnel (`wg` > `tcp-tls` > `wg-quic`).
- In `crates/tsunagi-wireguard/tests/plugin_tests.rs`:
- Add `test_wireguard_two_networks_same_peers` verifying simultaneous
bidirectional packet transmission between two peers sharing two networks
over a single `WireguardTransport`.
Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed (350+ tests passed).
379 lines
12 KiB
Rust
379 lines
12 KiB
Rust
#![allow(missing_docs)]
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use bytes::Bytes;
|
|
use tokio::sync::mpsc;
|
|
use tsunagi::dataplane::transport::{InboundLink, PacketTransport};
|
|
use tsunagi::dataplane::{IpPlugin, PacketSink, PluginContext};
|
|
use tsunagi::identity::{DeviceIdentity, NetworkId};
|
|
use tsunagi_wireguard::{WireguardConfig, WireguardPlugin, WireguardTransport};
|
|
|
|
struct TestSink {
|
|
delivered: mpsc::Sender<(NetworkId, iroh::EndpointId, Bytes)>,
|
|
}
|
|
|
|
impl PacketSink for TestSink {
|
|
fn deliver(
|
|
&self,
|
|
network: NetworkId,
|
|
from: iroh::EndpointId,
|
|
packet: Bytes,
|
|
) -> tsunagi::BoxFuture<'_, ()> {
|
|
let delivered = self.delivered.clone();
|
|
Box::pin(async move {
|
|
let _ = delivered.send((network, from, packet)).await;
|
|
})
|
|
}
|
|
}
|
|
|
|
fn sample_ipv4_packet() -> Bytes {
|
|
let mut packet = vec![0u8; 40];
|
|
packet[0] = 0x45; // IPv4, IHL 5
|
|
packet[2..4].copy_from_slice(&40u16.to_be_bytes()); // total len 40
|
|
packet[8] = 64; // TTL
|
|
packet[9] = 17; // UDP
|
|
packet[12..16].copy_from_slice(&[10, 0, 0, 1]); // src 10.0.0.1
|
|
packet[16..20].copy_from_slice(&[10, 0, 0, 2]); // dst 10.0.0.2
|
|
Bytes::from(packet)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_wireguard_plugin_end_to_end() {
|
|
let _ = tracing_subscriber::fmt()
|
|
.with_env_filter("trace")
|
|
.with_test_writer()
|
|
.try_init();
|
|
|
|
let identity_a = DeviceIdentity::generate();
|
|
let peer_a = identity_a.endpoint_id();
|
|
|
|
let identity_b = DeviceIdentity::generate();
|
|
let peer_b = identity_b.endpoint_id();
|
|
|
|
let network = NetworkId::from_bytes([77u8; 32]);
|
|
|
|
let tmp_a = tempfile::tempdir().expect("tmp A");
|
|
let tmp_b = tempfile::tempdir().expect("tmp B");
|
|
|
|
// Transports
|
|
let transport_b = Arc::new(
|
|
WireguardTransport::bind(&identity_b, Some(0))
|
|
.await
|
|
.expect("bind B"),
|
|
);
|
|
let port_b = transport_b.bound_port();
|
|
|
|
let transport_a = Arc::new(
|
|
WireguardTransport::bind(&identity_a, Some(0))
|
|
.await
|
|
.expect("bind A"),
|
|
);
|
|
let port_a = transport_a.bound_port();
|
|
|
|
// Plugins
|
|
let cfg_a = WireguardConfig::new(tmp_a.path());
|
|
let plugin_a = WireguardPlugin::open(cfg_a, Arc::clone(&transport_a))
|
|
.await
|
|
.expect("open plugin A");
|
|
|
|
let cfg_b = WireguardConfig::new(tmp_b.path());
|
|
let plugin_b = WireguardPlugin::open(cfg_b, Arc::clone(&transport_b))
|
|
.await
|
|
.expect("open plugin B");
|
|
|
|
// Attach sinks
|
|
let (sink_a_tx, mut sink_a_rx) = mpsc::channel(10);
|
|
plugin_a.attach(PluginContext::with_sink(Arc::new(TestSink {
|
|
delivered: sink_a_tx,
|
|
})));
|
|
|
|
let (sink_b_tx, mut sink_b_rx) = mpsc::channel(10);
|
|
plugin_b.attach(PluginContext::with_sink(Arc::new(TestSink {
|
|
delivered: sink_b_tx,
|
|
})));
|
|
|
|
// Capability exchange
|
|
plugin_a.on_network_activated(network);
|
|
plugin_b.on_network_activated(network);
|
|
|
|
let cap_a = loop {
|
|
if let Some(cap) = plugin_a.local_capability(network).expect("cap A") {
|
|
break cap;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
};
|
|
|
|
let cap_b = loop {
|
|
if let Some(cap) = plugin_b.local_capability(network).expect("cap B") {
|
|
break cap;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
};
|
|
|
|
plugin_a
|
|
.on_peer_capability(network, peer_b, &cap_b)
|
|
.expect("A on cap B");
|
|
plugin_b
|
|
.on_peer_capability(network, peer_a, &cap_a)
|
|
.expect("B on cap A");
|
|
|
|
// Register loopback address candidates
|
|
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
|
|
transport_a.set_peer_addr(peer_b, addr_b);
|
|
|
|
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
|
|
transport_b.set_peer_addr(peer_a, addr_a);
|
|
|
|
// Spawn accept loop on A
|
|
let transport_a_clone = Arc::clone(&transport_a);
|
|
tokio::spawn(async move {
|
|
transport_a_clone.accept_loop(|_| {}).await;
|
|
});
|
|
|
|
// Spawn accept loop on B
|
|
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(1);
|
|
let transport_b_clone = Arc::clone(&transport_b);
|
|
tokio::spawn(async move {
|
|
transport_b_clone
|
|
.accept_loop(move |inbound| {
|
|
let _ = inbound_tx.try_send(inbound);
|
|
})
|
|
.await;
|
|
});
|
|
|
|
// Open link from A to B
|
|
let link_a = tokio::time::timeout(
|
|
Duration::from_secs(5),
|
|
transport_a.open(network, peer_b, "wg"),
|
|
)
|
|
.await
|
|
.expect("open should not timeout")
|
|
.expect("open should succeed");
|
|
|
|
plugin_a.on_peer_link(network, peer_b, Arc::clone(&link_a));
|
|
|
|
// Wait for inbound link on B and hand to plugin B
|
|
let inbound_b = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
|
|
.await
|
|
.expect("accept should not timeout")
|
|
.expect("inbound link should arrive");
|
|
plugin_b.on_peer_link(network, peer_a, inbound_b.link);
|
|
|
|
// Wait briefly for WireGuard handshake to complete
|
|
let mut up = false;
|
|
for _ in 0..50 {
|
|
if let Some(overview_a) = plugin_a.overview(network)
|
|
&& overview_a.peers.iter().any(|p| p.is_up())
|
|
{
|
|
up = true;
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
assert!(up, "WireGuard handshake should complete between peers");
|
|
|
|
// Send IP packet from A to B
|
|
let packet_a_to_b = sample_ipv4_packet();
|
|
let sent = plugin_a.carry(network, peer_b, packet_a_to_b.clone());
|
|
assert!(sent, "plugin A carry should succeed");
|
|
|
|
let received_b = tokio::time::timeout(Duration::from_secs(5), sink_b_rx.recv())
|
|
.await
|
|
.expect("recv B timeout")
|
|
.expect("recv B option");
|
|
|
|
assert_eq!(received_b.0, network);
|
|
assert_eq!(received_b.1, peer_a);
|
|
assert_eq!(received_b.2, packet_a_to_b);
|
|
|
|
// Send IP packet from B to A
|
|
let packet_b_to_a = sample_ipv4_packet();
|
|
let sent_reply = plugin_b.carry(network, peer_a, packet_b_to_a.clone());
|
|
assert!(sent_reply, "plugin B carry should succeed");
|
|
|
|
let received_a = tokio::time::timeout(Duration::from_secs(5), sink_a_rx.recv())
|
|
.await
|
|
.expect("recv A timeout")
|
|
.expect("recv A option");
|
|
|
|
assert_eq!(received_a.0, network);
|
|
assert_eq!(received_a.1, peer_b);
|
|
assert_eq!(received_a.2, packet_b_to_a);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_wireguard_two_networks_same_peers() {
|
|
let identity_a = DeviceIdentity::generate();
|
|
let peer_a = identity_a.endpoint_id();
|
|
|
|
let identity_b = DeviceIdentity::generate();
|
|
let peer_b = identity_b.endpoint_id();
|
|
|
|
let net1 = NetworkId::from_bytes([11u8; 32]);
|
|
let net2 = NetworkId::from_bytes([22u8; 32]);
|
|
|
|
let tmp_a = tempfile::tempdir().expect("tmp A");
|
|
let tmp_b = tempfile::tempdir().expect("tmp B");
|
|
|
|
let transport_b = Arc::new(
|
|
WireguardTransport::bind(&identity_b, Some(0))
|
|
.await
|
|
.expect("bind B"),
|
|
);
|
|
let port_b = transport_b.bound_port();
|
|
|
|
let transport_a = Arc::new(
|
|
WireguardTransport::bind(&identity_a, Some(0))
|
|
.await
|
|
.expect("bind A"),
|
|
);
|
|
let port_a = transport_a.bound_port();
|
|
|
|
let plugin_a =
|
|
WireguardPlugin::open(WireguardConfig::new(tmp_a.path()), Arc::clone(&transport_a))
|
|
.await
|
|
.expect("open plugin A");
|
|
|
|
let plugin_b =
|
|
WireguardPlugin::open(WireguardConfig::new(tmp_b.path()), Arc::clone(&transport_b))
|
|
.await
|
|
.expect("open plugin B");
|
|
|
|
let (sink_a_tx, mut sink_a_rx) = mpsc::channel(20);
|
|
plugin_a.attach(PluginContext::with_sink(Arc::new(TestSink {
|
|
delivered: sink_a_tx,
|
|
})));
|
|
|
|
let (sink_b_tx, mut sink_b_rx) = mpsc::channel(20);
|
|
plugin_b.attach(PluginContext::with_sink(Arc::new(TestSink {
|
|
delivered: sink_b_tx,
|
|
})));
|
|
|
|
// Activate both networks
|
|
for net in [net1, net2] {
|
|
plugin_a.on_network_activated(net);
|
|
plugin_b.on_network_activated(net);
|
|
}
|
|
|
|
// Exchange capabilities for both networks
|
|
for net in [net1, net2] {
|
|
let cap_a = loop {
|
|
if let Some(cap) = plugin_a.local_capability(net).expect("cap A") {
|
|
break cap;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
};
|
|
let cap_b = loop {
|
|
if let Some(cap) = plugin_b.local_capability(net).expect("cap B") {
|
|
break cap;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
};
|
|
plugin_a
|
|
.on_peer_capability(net, peer_b, &cap_b)
|
|
.expect("A on cap B");
|
|
plugin_b
|
|
.on_peer_capability(net, peer_a, &cap_a)
|
|
.expect("B on cap A");
|
|
}
|
|
|
|
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
|
|
transport_a.set_peer_addr(peer_b, addr_b);
|
|
|
|
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
|
|
transport_b.set_peer_addr(peer_a, addr_a);
|
|
|
|
// Accept loops
|
|
let transport_a_clone = Arc::clone(&transport_a);
|
|
tokio::spawn(async move {
|
|
transport_a_clone.accept_loop(|_| {}).await;
|
|
});
|
|
|
|
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundLink>(10);
|
|
let transport_b_clone = Arc::clone(&transport_b);
|
|
tokio::spawn(async move {
|
|
transport_b_clone
|
|
.accept_loop(move |inbound| {
|
|
let _ = inbound_tx.try_send(inbound);
|
|
})
|
|
.await;
|
|
});
|
|
|
|
// Open links for both networks from A to B
|
|
for net in [net1, net2] {
|
|
let link_a = transport_a
|
|
.open(net, peer_b, "wg")
|
|
.await
|
|
.expect("open A link");
|
|
plugin_a.on_peer_link(net, peer_b, link_a);
|
|
}
|
|
|
|
// Deliver inbound links to B
|
|
for _ in 0..2 {
|
|
let inbound = tokio::time::timeout(Duration::from_secs(5), inbound_rx.recv())
|
|
.await
|
|
.expect("inbound timeout")
|
|
.expect("inbound option");
|
|
plugin_b.on_peer_link(inbound.network, peer_a, inbound.link);
|
|
}
|
|
|
|
// Wait for both handshakes to complete on both networks
|
|
for net in [net1, net2] {
|
|
let mut up = false;
|
|
for _ in 0..50 {
|
|
if let Some(overview) = plugin_a.overview(net)
|
|
&& overview.peers.iter().any(|p| p.is_up())
|
|
{
|
|
up = true;
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
assert!(up, "handshake on {net:?} should complete");
|
|
}
|
|
|
|
// Send on net1
|
|
let pkt1 = sample_ipv4_packet();
|
|
assert!(plugin_a.carry(net1, peer_b, pkt1.clone()));
|
|
let recv1 = tokio::time::timeout(Duration::from_secs(5), sink_b_rx.recv())
|
|
.await
|
|
.expect("recv1 timeout")
|
|
.expect("recv1 option");
|
|
assert_eq!(recv1.0, net1);
|
|
assert_eq!(recv1.2, pkt1);
|
|
|
|
// Send on net2 from A to B
|
|
let mut pkt2_bytes = sample_ipv4_packet().to_vec();
|
|
pkt2_bytes[15] = 99; // different byte
|
|
let pkt2 = Bytes::from(pkt2_bytes);
|
|
assert!(plugin_a.carry(net2, peer_b, pkt2.clone()));
|
|
let recv2 = tokio::time::timeout(Duration::from_secs(5), sink_b_rx.recv())
|
|
.await
|
|
.expect("recv2 timeout")
|
|
.expect("recv2 option");
|
|
assert_eq!(recv2.0, net2);
|
|
assert_eq!(recv2.2, pkt2);
|
|
|
|
// Send reply on net1 from B to A
|
|
assert!(plugin_b.carry(net1, peer_a, pkt1.clone()));
|
|
let reply1 = tokio::time::timeout(Duration::from_secs(5), sink_a_rx.recv())
|
|
.await
|
|
.expect("reply1 timeout")
|
|
.expect("reply1 option");
|
|
assert_eq!(reply1.0, net1);
|
|
assert_eq!(reply1.2, pkt1);
|
|
|
|
// Send reply on net2 from B to A
|
|
assert!(plugin_b.carry(net2, peer_a, pkt2.clone()));
|
|
let reply2 = tokio::time::timeout(Duration::from_secs(5), sink_a_rx.recv())
|
|
.await
|
|
.expect("reply2 timeout")
|
|
.expect("reply2 option");
|
|
assert_eq!(reply2.0, net2);
|
|
assert_eq!(reply2.2, pkt2);
|
|
}
|