Fix WireGuard relay preemption with UDP probing, allow VM bridges, and categorize status addresses
When remote peers communicate across networks with mixed connectivity (e.g.
a remote Linux node, a local Linux host, and a Windows VM guest under KVM):
- Previously, `WireguardTransport` immediately created and installed
unverified direct links into `RelayHub` in `notify_peer_capability` and
`open()`. When a remote peer had only unroutable private addresses
(e.g. 192.168.122.151 on a KVM NAT bridge), this dead direct link
preempted the working 2-hop relay, causing packet drops, failed handshakes,
and REKEY_TIMEOUT errors.
- Host-guest VM bridges (`virbr*`) were previously filtered out, preventing
the VM guest from discovering the host's bridge IP (192.168.122.1) and
forcing it to attempt cross-interface connections to host Wi-Fi/LAN.
- Concurrent activation of multiple networks sharing one `tsun0` interface
raced in `sync_addresses`, resulting in netlink `File exists (os error 17)`
and `The object already exists` on Windows.
Key fixes:
- In `crates/tsunagi-wireguard/src/transport.rs`:
- Implement bidirectional UDP probing with `TSUNAGI_WG_PROBE_V1` PING/PONG.
- In `open()`, probe candidate endpoints with retransmission up to 1200ms.
If unreachable, return `TransportError::Unreachable`, preventing dead
links from preempting valid relay routes in `RelayHub`.
- In `notify_peer_capability`, send hole-punching probes without prematurely
installing unverified links.
- In `accept_loop`, reply to PING probes with PONG and install confirmed
inbound links targeting the verified source address.
- In `WireguardLink::send`, inspect byte 74 of enveloped packets to detect
handshake initiations, ensuring confirmed links route data packets only
to the active target.
- In `crates/tsunagi-wireguard/src/plugin.rs`:
- Permit VM bridges (`virbr*`) as candidate interfaces (priority 10).
- Add `prioritize_local_subnet` to prioritize candidates sharing a local
IPv4 subnet with the receiver (e.g. 192.168.122.0/24).
- In `crates/tsunagi/src/overlay/interface.rs` & provisioners:
- Add async mutex `sync_lock` to serialize concurrent address syncs across networks.
- Handle netlink EEXIST (os error 17) and Windows already-exists errors
idempotently as success.
- In `crates/tsunagi-cli/src/main.rs`:
- Split `local addresses` in `tsunagi status` into `announced` (candidate
addresses in priority order with interface and rank), `not announced`
(container bridges/internal addresses with interface name), and `loopback`.
- Tests:
- Add `test_wireguard_transport_unreachable_probe_times_out` and
`test_matches_subnet` unit tests.
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
c791102ad9
commit
35acc4d231
8 files changed
+479
-103
No files matched your search
@@ -3015,23 +3015,73 @@ async fn addresses_section() -> report::Section {
|
||||
use report::{Health, Row, Section};
|
||||
|
||||
let mut addresses = Section::new("local addresses");
|
||||
let found = netwatch_addresses().await;
|
||||
if found.is_empty() {
|
||||
let found = netwatch_interface_addresses().await;
|
||||
|
||||
let mut candidates = tsunagi_wireguard::local_ip_candidates();
|
||||
for ip in tsunagi_tcp_tls::local_ip_candidates() {
|
||||
if !candidates.contains(&ip) {
|
||||
candidates.push(ip);
|
||||
}
|
||||
}
|
||||
|
||||
if found.is_empty() && candidates.is_empty() {
|
||||
addresses.push(
|
||||
Row::new(Health::Degraded, "interfaces", "none found")
|
||||
.with_note("best effort; the agent may still find a way out"),
|
||||
);
|
||||
return addresses;
|
||||
}
|
||||
for addr in found {
|
||||
// Loopback alone reaches nobody, but on a host that also has a real
|
||||
// address it is unremarkable, so it is labelled rather than flagged.
|
||||
let kind = match (addr.is_loopback(), addr.is_ipv4()) {
|
||||
(true, _) => "loopback",
|
||||
(false, true) => "ipv4",
|
||||
(false, false) => "ipv6",
|
||||
};
|
||||
addresses.push(Row::new(Health::Info, kind, addr.to_string()));
|
||||
|
||||
let mut iface_map: std::collections::HashMap<std::net::IpAddr, String> =
|
||||
std::collections::HashMap::new();
|
||||
for item in &found {
|
||||
iface_map.insert(item.addr, item.iface.clone());
|
||||
}
|
||||
|
||||
// 1. Announced peer candidate addresses (in candidate priority order)
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (idx, &cand) in candidates.iter().enumerate() {
|
||||
if seen.insert(cand) {
|
||||
let note = iface_map
|
||||
.get(&cand)
|
||||
.map(|iface| format!("{iface} · candidate #{}", idx + 1));
|
||||
let row = Row::new(Health::Info, "announced", cand.to_string());
|
||||
addresses.push(match note {
|
||||
Some(n) => row.with_note(n),
|
||||
None => row,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Unannounced local addresses
|
||||
let mut unannounced = Vec::new();
|
||||
let mut loopbacks = Vec::new();
|
||||
|
||||
for item in &found {
|
||||
if seen.contains(&item.addr) {
|
||||
continue;
|
||||
}
|
||||
if item.addr.is_loopback() {
|
||||
loopbacks.push(item);
|
||||
} else {
|
||||
unannounced.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
unannounced.sort_by_key(|item| item.addr);
|
||||
for item in unannounced {
|
||||
let note = format!("{} · not announced to peers", item.iface);
|
||||
addresses
|
||||
.push(Row::new(Health::Info, "not announced", item.addr.to_string()).with_note(note));
|
||||
}
|
||||
|
||||
// 3. Loopback addresses
|
||||
loopbacks.sort_by_key(|item| item.addr);
|
||||
for item in loopbacks {
|
||||
addresses
|
||||
.push(Row::new(Health::Info, "loopback", item.addr.to_string()).with_note(&item.iface));
|
||||
}
|
||||
|
||||
addresses
|
||||
}
|
||||
|
||||
@@ -3433,21 +3483,31 @@ fn program_path() -> String {
|
||||
.unwrap_or_else(|| "tsunagi".to_string())
|
||||
}
|
||||
|
||||
async fn netwatch_addresses() -> Vec<std::net::IpAddr> {
|
||||
struct LocalAddressInfo {
|
||||
addr: std::net::IpAddr,
|
||||
iface: String,
|
||||
}
|
||||
|
||||
async fn netwatch_interface_addresses() -> Vec<LocalAddressInfo> {
|
||||
// Best effort; used for diagnostics only.
|
||||
let state = netwatch::interfaces::State::new().await;
|
||||
let mut addresses = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (name, iface) in &state.interfaces {
|
||||
let name_lower = name.to_lowercase();
|
||||
if name_lower.starts_with("tsun") || name_lower.contains("tsunagi") {
|
||||
continue;
|
||||
}
|
||||
for prefix in iface.addrs() {
|
||||
addresses.push(prefix.addr());
|
||||
let addr = prefix.addr();
|
||||
if seen.insert(addr) {
|
||||
addresses.push(LocalAddressInfo {
|
||||
addr,
|
||||
iface: name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
addresses.sort();
|
||||
addresses.dedup();
|
||||
addresses
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user