Fix WireGuard multi-network demux, candidate discovery, and status reporting

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).
This commit is contained in:
Aleksandr Bogomiakov committed 2026-10-01 01:38:44 +01:00
1 parent 7070a16104
commit f80841da63
4 files changed
+434 -185

No files matched your search

+119 -107
View File
@@ -19,7 +19,7 @@ use tsunagi::discovery::{
CompositeDiscovery, MainlineDiscovery, NetworkDiscovery, StaticBootstrap,
};
use tsunagi::identity::{NetworkName, NetworkSecret};
use tsunagi::iroh_types::EndpointAddr;
use tsunagi::iroh_types::{EndpointAddr, EndpointId};
use tsunagi::overlay::{MemoryTunFactory, TunFactory};
use tsunagi::state::Ipv4Range;
use tsunagi::{Agent, NetworkId};
@@ -3864,125 +3864,137 @@ async fn build_report(
let overlay = if has_any_plugin && overlay.is_some() {
let mut peers: Vec<OverlayPeerReport> = Vec::new();
// 1. Peers known to the wg-quic plugin.
if let Some(ref view) = wg_view {
for peer in &view.peers {
let tcp_tls_match =
tcp_tls_reports.iter().find(|r| r.peer == peer.endpoint_id);
if let Some(tcp_tls) = tcp_tls_match {
peers.push(OverlayPeerReport {
endpoint_id: peer.endpoint_id.to_string(),
protocol: tcp_tls.protocol.clone(),
public_key: peer.public_key.to_string(),
address: peer.overlay_address_v4.map(|addr| addr.to_string()),
handshake_secs_ago: Some(tcp_tls.uptime_secs),
tx_packets: tcp_tls.tx_packets,
rx_packets: tcp_tls.rx_packets,
dropped: tcp_tls.dropped,
protocol_errors: 0,
path: tcp_tls.path.clone(),
});
} else if let Some(tunnel) = &peer.tunnel {
peers.push(OverlayPeerReport {
endpoint_id: peer.endpoint_id.to_string(),
protocol: "wg-quic".into(),
public_key: peer.public_key.to_string(),
address: peer.overlay_address_v4.map(|addr| addr.to_string()),
handshake_secs_ago: tunnel
.health
.since_handshake
.map(|s| s.as_secs()),
tx_packets: tunnel.stats.tx_packets,
rx_packets: tunnel.stats.rx_packets,
dropped: tunnel.stats.dropped_wrong_source
+ tunnel.stats.dropped_oversize,
protocol_errors: tunnel.stats.protocol_errors,
path: tunnel.path.clone(),
});
} else {
peers.push(OverlayPeerReport {
endpoint_id: peer.endpoint_id.to_string(),
protocol: "wg-quic".into(),
public_key: peer.public_key.to_string(),
address: peer.overlay_address_v4.map(|addr| addr.to_string()),
handshake_secs_ago: None,
tx_packets: 0,
rx_packets: 0,
dropped: 0,
protocol_errors: 0,
path: "no data link".into(),
});
}
// Collect all unique peer IDs known in this network.
let mut all_peer_ids: Vec<EndpointId> = Vec::new();
for member in &network.members {
if member.endpoint_id != status.endpoint_id
&& !all_peer_ids.contains(&member.endpoint_id)
{
all_peer_ids.push(member.endpoint_id);
}
}
// 2. Peers known to the pure WG plugin but not yet covered.
if let Some(ref view) = pure_wg_view {
for peer in &view.peers {
if peers
.iter()
.any(|p| p.endpoint_id == peer.endpoint_id.to_string())
{
continue;
if !all_peer_ids.contains(&peer.endpoint_id) {
all_peer_ids.push(peer.endpoint_id);
}
if let Some(tunnel) = &peer.tunnel {
peers.push(OverlayPeerReport {
endpoint_id: peer.endpoint_id.to_string(),
protocol: "wg".into(),
public_key: peer.public_key.to_string(),
address: peer.overlay_address_v4.map(|addr| addr.to_string()),
handshake_secs_ago: tunnel
.health
.since_handshake
.map(|s| s.as_secs()),
tx_packets: tunnel.stats.tx_packets,
rx_packets: tunnel.stats.rx_packets,
dropped: tunnel.stats.dropped_wrong_source
+ tunnel.stats.dropped_oversize,
protocol_errors: tunnel.stats.protocol_errors,
path: tunnel.path.clone(),
});
} else {
peers.push(OverlayPeerReport {
endpoint_id: peer.endpoint_id.to_string(),
protocol: "wg".into(),
public_key: peer.public_key.to_string(),
address: peer.overlay_address_v4.map(|addr| addr.to_string()),
handshake_secs_ago: None,
tx_packets: 0,
rx_packets: 0,
dropped: 0,
protocol_errors: 0,
path: "no data link".into(),
});
}
}
if let Some(ref view) = wg_view {
for peer in &view.peers {
if !all_peer_ids.contains(&peer.endpoint_id) {
all_peer_ids.push(peer.endpoint_id);
}
}
}
// 3. tcp-tls peers not yet covered by either WG view.
for report in &tcp_tls_reports {
if !peers
if !all_peer_ids.contains(&report.peer) {
all_peer_ids.push(report.peer);
}
}
for peer_id in all_peer_ids {
let pure_wg_match = pure_wg_view
.as_ref()
.and_then(|v| v.peers.iter().find(|p| p.endpoint_id == peer_id));
let tcp_tls_match = tcp_tls_reports.iter().find(|r| r.peer == peer_id);
let wg_quic_match = wg_view
.as_ref()
.and_then(|v| v.peers.iter().find(|p| p.endpoint_id == peer_id));
let member_addr = network
.members
.iter()
.any(|p| p.endpoint_id == report.peer.to_string())
.find(|m| m.endpoint_id == peer_id)
.and_then(|m| m.overlay_address_v4)
.map(|a| a.to_string());
if let Some(pure_peer) = pure_wg_match
&& let Some(tunnel) = &pure_peer.tunnel
{
let address = network
.members
.iter()
.find(|m| m.endpoint_id == report.peer)
.and_then(|m| m.overlay_address_v4)
.map(|a| a.to_string());
peers.push(OverlayPeerReport {
endpoint_id: report.peer.to_string(),
protocol: report.protocol.clone(),
public_key: String::new(),
address,
handshake_secs_ago: Some(report.uptime_secs),
tx_packets: report.tx_packets,
rx_packets: report.rx_packets,
dropped: report.dropped,
endpoint_id: peer_id.to_string(),
protocol: "wg".into(),
public_key: pure_peer.public_key.to_string(),
address: pure_peer
.overlay_address_v4
.map(|a| a.to_string())
.or(member_addr),
handshake_secs_ago: tunnel.health.since_handshake.map(|s| s.as_secs()),
tx_packets: tunnel.stats.tx_packets,
rx_packets: tunnel.stats.rx_packets,
dropped: tunnel.stats.dropped_wrong_source
+ tunnel.stats.dropped_oversize,
protocol_errors: tunnel.stats.protocol_errors,
path: tunnel.path.clone(),
});
} else if let Some(tcp_tls) = tcp_tls_match {
let pubkey = pure_wg_match
.map(|p| p.public_key.to_string())
.or_else(|| wg_quic_match.map(|p| p.public_key.to_string()))
.unwrap_or_default();
peers.push(OverlayPeerReport {
endpoint_id: peer_id.to_string(),
protocol: tcp_tls.protocol.clone(),
public_key: pubkey,
address: member_addr,
handshake_secs_ago: Some(tcp_tls.uptime_secs),
tx_packets: tcp_tls.tx_packets,
rx_packets: tcp_tls.rx_packets,
dropped: tcp_tls.dropped,
protocol_errors: 0,
path: report.path.clone(),
path: tcp_tls.path.clone(),
});
} else if let Some(wg_peer) = wg_quic_match
&& let Some(tunnel) = &wg_peer.tunnel
{
peers.push(OverlayPeerReport {
endpoint_id: peer_id.to_string(),
protocol: "wg-quic".into(),
public_key: wg_peer.public_key.to_string(),
address: wg_peer
.overlay_address_v4
.map(|a| a.to_string())
.or(member_addr),
handshake_secs_ago: tunnel.health.since_handshake.map(|s| s.as_secs()),
tx_packets: tunnel.stats.tx_packets,
rx_packets: tunnel.stats.rx_packets,
dropped: tunnel.stats.dropped_wrong_source
+ tunnel.stats.dropped_oversize,
protocol_errors: tunnel.stats.protocol_errors,
path: tunnel.path.clone(),
});
} else if let Some(pure_peer) = pure_wg_match {
peers.push(OverlayPeerReport {
endpoint_id: peer_id.to_string(),
protocol: "wg".into(),
public_key: pure_peer.public_key.to_string(),
address: pure_peer
.overlay_address_v4
.map(|a| a.to_string())
.or(member_addr),
handshake_secs_ago: None,
tx_packets: 0,
rx_packets: 0,
dropped: 0,
protocol_errors: 0,
path: "no data link".into(),
});
} else if let Some(wg_peer) = wg_quic_match {
peers.push(OverlayPeerReport {
endpoint_id: peer_id.to_string(),
protocol: "wg-quic".into(),
public_key: wg_peer.public_key.to_string(),
address: wg_peer
.overlay_address_v4
.map(|a| a.to_string())
.or(member_addr),
handshake_secs_ago: None,
tx_packets: 0,
rx_packets: 0,
dropped: 0,
protocol_errors: 0,
path: "no data link".into(),
});
}
}