Files
tsunagi/crates/tsunagi-wireguard/tests/transport_tests.rs
T
Aleksandr Bogomiakov 3930dff2dc Detect WireGuard tunnel rekey failure and trigger automatic fallback
Previously, because UDP is connectionless, when a pure WireGuard tunnel
encountered handshake or rekey failures (e.g. boringtun logging
`WARN HANDSHAKE(REKEY_TIMEOUT)` and `CONNECTION_EXPIRED`), the
`WireguardLink` never closed. It remained active in `agent.links` and
as a direct hop in `RelayHub.raw`. Consequently, outgoing packets
continued to be blackholed over the dead direct path, preventing
`RelayHub` from using working multihop relay routes and preventing
the agent from falling back to alternative protocols (`tcp-tls` or
`wg-quic`).

Key changes:
- In `crates/tsunagi-wireguard/src/device.rs`:
  - In `FlowTunnel`, track active handshake and rekey attempts via
    `handshake_started_at`. If an active handshake attempt remains
    unanswered beyond `HANDSHAKE_TIMEOUT` (15s) or boringtun reports
    `ConnectionExpired`, declare the tunnel dead.
  - Reset timeout timers upon successful handshake reception in
    `decapsulate`.
  - In `drive_timers`, when tunnel failure is detected, trigger
    `inner.on_tunnel_failed(peer)`, which closes the direct transport
    link and notifies the agent via `PluginContext::report_peer_protocol_failure`.
- In `crates/tsunagi-wireguard/src/transport.rs`:
  - Add `WireguardLink::close` to transition `is_closed` to true and
    notify `closed_notify`.
  - In `WireguardLink::recv`, select on `closed_notify` so readers
    unblock and return `None` immediately on close.
  - Add `WireguardTransport::close_link` to close active links by
    network and peer.
- In `crates/tsunagi/src/dataplane/mod.rs`:
  - Add `PluginRequest::ProtocolFailure` and public helper
    `PluginContext::report_peer_protocol_failure`.
- In `crates/tsunagi/src/agent/mod.rs` and `crates/tsunagi/src/agent/network.rs`:
  - Add `NetCommand::ProtocolFailure` to runtime command handling.
  - On protocol failure: remove dead direct link from `self.links`,
    clear it from `RelayHub.raw` (so `RelayHub` immediately routes
    over available multihop relays), record failure on `wg` for that
    peer with exponential backoff cooldown, emit `DataLinkDown`, and
    immediately call `ensure_links()` to dial the next mutually supported
    protocol (`tcp-tls` or `wg-quic`).
- Tests:
  - Add `test_flow_tunnel_rekey_timeout_triggers_failure` and
    `test_flow_tunnel_reset_on_handshake` unit tests in `device.rs`.
  - Add `test_wireguard_link_close_and_transport_close_link` in
    `transport_tests.rs`.
  - Add `test_protocol_dial_state_cooldown_and_fallback_selection` in
    `network.rs`.

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).
- Built release binary: target/release/tsunagi.
2026-10-01 03:02:09 +01:00

221 lines
7.4 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::identity::{DeviceIdentity, NetworkId};
use tsunagi_wireguard::WireguardTransport;
#[tokio::test]
async fn test_wireguard_transport_loopback() {
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([42u8; 32]);
// Bind transport B on port 0 (ephemeral)
let transport_b = Arc::new(
WireguardTransport::bind(&identity_b, Some(0))
.await
.expect("bind server should succeed"),
);
let port_b = transport_b.bound_port();
assert!(port_b > 0);
// Bind transport A on port 0 (ephemeral)
let transport_a = Arc::new(
WireguardTransport::bind(&identity_a, Some(0))
.await
.expect("bind client should succeed"),
);
let port_a = transport_a.bound_port();
assert!(port_a > 0);
// Register B's address and network on transport A
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
transport_a.set_peer_addr(peer_b, addr_b);
transport_a.set_peer_network(peer_b, network);
// Register A's address and network on transport B
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
transport_b.set_peer_addr(peer_a, addr_a);
transport_b.set_peer_network(peer_a, network);
// 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");
// Send from A to B to trigger inbound link discovery on B
let data_a_to_b = Bytes::from_static(b"wireguard packet from A to B");
link_a.send(data_a_to_b.clone()).expect("send A->B");
// Receive inbound link on 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");
assert_eq!(inbound_b.network, network);
assert_eq!(inbound_b.peer, peer_a);
assert_eq!(inbound_b.protocol, "wg");
let link_b = inbound_b.link;
// Check packet received on B
let received_on_b = tokio::time::timeout(Duration::from_secs(2), link_b.recv())
.await
.expect("recv B timeout")
.expect("recv B option");
assert_eq!(received_on_b, data_a_to_b);
// Send from B to A
let data_b_to_a = Bytes::from_static(b"wireguard packet from B to A");
link_b.send(data_b_to_a.clone()).expect("send B->A");
let received_on_a = tokio::time::timeout(Duration::from_secs(2), link_a.recv())
.await
.expect("recv A timeout")
.expect("recv A option");
assert_eq!(received_on_a, data_b_to_a);
// Path descriptions
assert!(link_a.path_description().contains("wireguard via"));
assert!(link_b.path_description().contains("wireguard via"));
}
#[tokio::test]
async fn test_wireguard_transport_unreachable_probe_times_out() {
let identity_a = DeviceIdentity::generate();
let identity_b = DeviceIdentity::generate();
let peer_b = identity_b.endpoint_id();
let network = NetworkId::from_bytes([99u8; 32]);
let transport_a = Arc::new(
WireguardTransport::bind(&identity_a, Some(0))
.await
.expect("bind client should succeed"),
);
// Register a non-responsive address for peer B (TEST-NET-1 documentation IP which drops packets)
let unreachable_addr: SocketAddr = "192.0.2.1:51820".parse().unwrap();
transport_a.set_peer_addr(peer_b, unreachable_addr);
transport_a.set_peer_network(peer_b, network);
// Spawn accept loop on A
let transport_a_clone = Arc::clone(&transport_a);
tokio::spawn(async move {
transport_a_clone.accept_loop(|_| {}).await;
});
// Opening to unreachable candidate must fail with Unreachable rather than returning a dead direct link
let result = transport_a.open(network, peer_b, "wg").await;
match result {
Err(tsunagi::dataplane::transport::TransportError::Unreachable(msg)) => {
assert!(msg.contains("timed out"));
}
other => panic!("expected Unreachable error, got {other:?}"),
}
}
#[tokio::test]
async fn test_wireguard_link_close_and_transport_close_link() {
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([55u8; 32]);
let transport_b = Arc::new(
WireguardTransport::bind(&identity_b, Some(0))
.await
.unwrap(),
);
let port_b = transport_b.bound_port();
let transport_a = Arc::new(
WireguardTransport::bind(&identity_a, Some(0))
.await
.unwrap(),
);
let port_a = transport_a.bound_port();
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
transport_a.set_peer_addr(peer_b, addr_b);
transport_a.set_peer_network(peer_b, network);
let addr_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().unwrap();
transport_b.set_peer_addr(peer_a, addr_a);
transport_b.set_peer_network(peer_a, network);
let transport_a_clone = Arc::clone(&transport_a);
tokio::spawn(async move {
transport_a_clone.accept_loop(|_| {}).await;
});
let transport_b_clone = Arc::clone(&transport_b);
tokio::spawn(async move {
transport_b_clone.accept_loop(|_| {}).await;
});
let link_a = transport_a
.open(network, peer_b, "wg")
.await
.expect("open link A");
assert!(!link_a.is_closed());
// Spawn a reader task waiting on recv()
let link_a_clone = Arc::clone(&link_a);
let recv_task = tokio::spawn(async move { link_a_clone.recv().await });
// Explicitly closing via transport.close_link()
transport_a.close_link(network, peer_b);
assert!(link_a.is_closed());
// Wait on closed() notification
tokio::time::timeout(Duration::from_secs(1), link_a.closed())
.await
.expect("link.closed() should resolve promptly");
// recv() must have returned None
let recv_res = tokio::time::timeout(Duration::from_secs(1), recv_task)
.await
.expect("recv task should complete")
.expect("task join");
assert_eq!(recv_res, None);
// Send after close must return Closed error
let send_res = link_a.send(Bytes::from_static(b"after close"));
assert!(matches!(
send_res,
Err(tsunagi::dataplane::transport::TransportError::Closed)
));
}