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:
1 parent
7070a16104
commit
f80841da63
4 files changed
+434
-185
No files matched your search
+119
-107
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user