Fix overlay and virtual interface candidate leakage and WireGuard rekeying
On Windows (Wintun), iface.name in netdev is an adapter GUID, while
iface.friendly_name is 'tsun0'. Checking only iface.name failed to
identify and exclude Wintun overlay interfaces. Consequently, Windows
nodes announced their own overlay IP addresses (e.g. 10.13.37.26) as
candidate endpoints. Remote peers latched onto this overlay IP as the
WireGuard target, routing encrypted packets into tsun0 (recursive loop),
resulting in failed handshakes and subsequent HANDSHAKE(REKEY_TIMEOUT)
and CONNECTION_EXPIRED.
Similarly, Docker bridges (br-*, docker0) and virtual bridges were
announced and alphabetically sorted before physical LAN or Tailscale
interfaces, causing peers to attempt WireGuard handshakes with internal
Docker bridge addresses (e.g. 172.21.0.1).
Key changes:
- In crates/tsunagi-wireguard and crates/tsunagi-tcp-tls:
- Robustly identify overlay interfaces by inspecting iface.name,
iface.friendly_name, and iface.description for 'tsun', 'tsunagi',
and 'wintun'.
- Filter out container and virtual bridge interfaces (docker, br-,
veth, virbr, cni, flannel, cali, kube, dummy, lxc, podman, vethernet,
vboxnet, vmnet).
- Prioritize candidates by physical interface score: interfaces with
a default gateway (physical LAN/WAN) and Tailscale (100.64.0.0/10)
score highest and sort to candidate #0.
- In on_peer_capability, filter out any announced address falling inside
any active overlay IPv4 range (defense in depth on receiver).
- In crates/tsunagi-wireguard/src/transport.rs:
- In WireguardLink::send, broadcast Handshake Initiation packets (type 1)
to all candidate addresses so initial handshakes and rekeys probe all
candidate paths and recover if an endpoint roamed or NAT expired.
- In accept_loop, prioritize exact socket address matches before falling
back to candidate IP matches for NAT port roaming.
- In crates/tsunagi/src/net.rs:
- Update iroh AddrFilter to inspect friendly_name and description,
preventing Wintun overlay IPs from leaking into iroh discovery endpoints.
- Tests:
- Add unit tests for candidate interface filtering, IP validation, and
priority scoring in tsunagi-wireguard and tsunagi-tcp-tls.
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
f80841da63
commit
c791102ad9
4 files changed
+589
-32
No files matched your search
@@ -63,30 +63,147 @@ pub struct TcpTlsAnnouncement {
|
|||||||
|
|
||||||
/// Discovers local network interface IPs to announce to peers.
|
/// Discovers local network interface IPs to announce to peers.
|
||||||
pub fn local_ip_candidates() -> Vec<std::net::IpAddr> {
|
pub fn local_ip_candidates() -> Vec<std::net::IpAddr> {
|
||||||
let mut ips = Vec::new();
|
let mut candidates = Vec::new();
|
||||||
for iface in netdev::get_interfaces() {
|
for iface in netdev::get_interfaces() {
|
||||||
if iface.is_up() && !iface.is_loopback() {
|
if !is_candidate_interface(&iface) {
|
||||||
let name_lower = iface.name.to_lowercase();
|
|
||||||
if name_lower.starts_with("tsun") || name_lower.contains("tsunagi") {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for ip in iface.ipv4 {
|
for ip in &iface.ipv4 {
|
||||||
let addr = ip.addr();
|
let addr = ip.addr();
|
||||||
if !addr.is_loopback() && !addr.is_unspecified() {
|
if is_candidate_ipv4(addr) {
|
||||||
ips.push(std::net::IpAddr::V4(addr));
|
let priority = ip_priority(&iface, std::net::IpAddr::V4(addr));
|
||||||
|
candidates.push((priority, std::net::IpAddr::V4(addr)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for ip in iface.ipv6 {
|
for ip in &iface.ipv6 {
|
||||||
let addr = ip.addr();
|
let addr = ip.addr();
|
||||||
if !addr.is_loopback() && !addr.is_unspecified() {
|
if is_candidate_ip(std::net::IpAddr::V6(addr)) {
|
||||||
ips.push(std::net::IpAddr::V6(addr));
|
let priority = ip_priority(&iface, std::net::IpAddr::V6(addr));
|
||||||
|
candidates.push((priority, std::net::IpAddr::V6(addr)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sort descending by priority so best interface IP is candidate #0
|
||||||
|
candidates.sort_by_key(|b| std::cmp::Reverse(b.0));
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut ips = Vec::new();
|
||||||
|
for (_, ip) in candidates {
|
||||||
|
if seen.insert(ip) {
|
||||||
|
ips.push(ip);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ips
|
ips
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_candidate_interface(iface: &netdev::Interface) -> bool {
|
||||||
|
if !iface.is_up() || iface.is_loopback() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = iface.name.to_lowercase();
|
||||||
|
let friendly = iface
|
||||||
|
.friendly_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::to_lowercase)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let desc = iface
|
||||||
|
.description
|
||||||
|
.as_deref()
|
||||||
|
.map(str::to_lowercase)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Skip Tsunagi / Wintun overlay interfaces
|
||||||
|
if name.starts_with("tsun")
|
||||||
|
|| name.contains("tsunagi")
|
||||||
|
|| name.contains("wintun")
|
||||||
|
|| friendly.starts_with("tsun")
|
||||||
|
|| friendly.contains("tsunagi")
|
||||||
|
|| friendly.contains("wintun")
|
||||||
|
|| desc.contains("wintun")
|
||||||
|
|| desc.contains("tsunagi")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip container, VM, and virtual bridge interfaces
|
||||||
|
if name.starts_with("docker")
|
||||||
|
|| name.starts_with("br-")
|
||||||
|
|| name.starts_with("veth")
|
||||||
|
|| name.starts_with("virbr")
|
||||||
|
|| name.starts_with("cni")
|
||||||
|
|| name.starts_with("flannel")
|
||||||
|
|| name.starts_with("cali")
|
||||||
|
|| name.starts_with("kube")
|
||||||
|
|| name.starts_with("dummy")
|
||||||
|
|| name.starts_with("lxc")
|
||||||
|
|| name.starts_with("podman")
|
||||||
|
|| friendly.contains("vethernet")
|
||||||
|
|| friendly.contains("vboxnet")
|
||||||
|
|| friendly.contains("vmnet")
|
||||||
|
|| desc.contains("virtualbox")
|
||||||
|
|| desc.contains("hyper-v")
|
||||||
|
|| desc.contains("vmware")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_candidate_ipv4(addr: std::net::Ipv4Addr) -> bool {
|
||||||
|
if addr.is_loopback() || addr.is_unspecified() || addr.is_link_local() || addr.is_broadcast() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if addr.is_documentation() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_candidate_ip(addr: std::net::IpAddr) -> bool {
|
||||||
|
match addr {
|
||||||
|
std::net::IpAddr::V4(v4) => is_candidate_ipv4(v4),
|
||||||
|
std::net::IpAddr::V6(v6) => {
|
||||||
|
if v6.is_loopback() || v6.is_unspecified() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let segments = v6.segments();
|
||||||
|
// Filter link-local fe80::/10
|
||||||
|
if (segments[0] & 0xffc0) == 0xfe80 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn ip_priority(iface: &netdev::Interface, ip: std::net::IpAddr) -> u32 {
|
||||||
|
let has_gateway = iface.gateway.is_some();
|
||||||
|
let base = if has_gateway { 100 } else { 0 };
|
||||||
|
match ip {
|
||||||
|
std::net::IpAddr::V4(v4) => {
|
||||||
|
let octets = v4.octets();
|
||||||
|
// Tailscale: 100.64.0.0/10
|
||||||
|
let is_tailscale = octets[0] == 100 && (octets[1] & 0xc0) == 64;
|
||||||
|
let is_private = v4.is_private();
|
||||||
|
if !is_private && !is_tailscale {
|
||||||
|
base + 50
|
||||||
|
} else if has_gateway && is_private {
|
||||||
|
base + 40
|
||||||
|
} else if is_tailscale {
|
||||||
|
base + 30
|
||||||
|
} else {
|
||||||
|
base + 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::net::IpAddr::V6(v6) => {
|
||||||
|
let segments = v6.segments();
|
||||||
|
let is_global = (segments[0] & 0xe000) == 0x2000;
|
||||||
|
if is_global { base + 45 } else { base + 20 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The codec implementing cryptographic transformation and announcements for `tcp-tls`.
|
/// The codec implementing cryptographic transformation and announcements for `tcp-tls`.
|
||||||
pub struct TcpTlsCodec {
|
pub struct TcpTlsCodec {
|
||||||
local_id: EndpointId,
|
local_id: EndpointId,
|
||||||
@@ -198,9 +315,11 @@ impl TunnelCodec for TcpTlsCodec {
|
|||||||
.map_err(|err| PluginError::Rejected(format!("invalid announcement: {err}")))?;
|
.map_err(|err| PluginError::Rejected(format!("invalid announcement: {err}")))?;
|
||||||
|
|
||||||
for ip in &announcement.addrs {
|
for ip in &announcement.addrs {
|
||||||
|
if is_candidate_ip(*ip) {
|
||||||
self.transport
|
self.transport
|
||||||
.set_peer_addr(peer, std::net::SocketAddr::new(*ip, announcement.port));
|
.set_peer_addr(peer, std::net::SocketAddr::new(*ip, announcement.port));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let peer_noise_pubkey = PublicKey::from(announcement.noise_public);
|
let peer_noise_pubkey = PublicKey::from(announcement.noise_public);
|
||||||
let cipher = Arc::new(PeerCipher::new(
|
let cipher = Arc::new(PeerCipher::new(
|
||||||
@@ -271,3 +390,141 @@ impl TunnelCodec for TcpTlsCodec {
|
|||||||
|
|
||||||
/// The complete `tcp-tls` plugin.
|
/// The complete `tcp-tls` plugin.
|
||||||
pub type TcpTlsPlugin = GenericTunnelPlugin<TcpTlsCodec>;
|
pub type TcpTlsPlugin = GenericTunnelPlugin<TcpTlsCodec>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_candidate_interface_filters_overlay_and_bridges() {
|
||||||
|
let mut iface = netdev::Interface::dummy();
|
||||||
|
iface.flags = 1; // UP
|
||||||
|
|
||||||
|
// Standard Ethernet - should pass
|
||||||
|
iface.name = "eth0".to_string();
|
||||||
|
assert!(is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Windows Ethernet with GUID name - should pass
|
||||||
|
iface.name = "{B6149D2C-1234-5678-9ABC-DEF012345678}".to_string();
|
||||||
|
iface.friendly_name = Some("Ethernet".to_string());
|
||||||
|
iface.description = Some("Intel(R) Ethernet Connection".to_string());
|
||||||
|
assert!(is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Linux tsun0 overlay - must be filtered
|
||||||
|
iface.name = "tsun0".to_string();
|
||||||
|
iface.friendly_name = None;
|
||||||
|
iface.description = None;
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Windows Wintun overlay (GUID name with tsun0 friendly name) - must be filtered!
|
||||||
|
iface.name = "{72D85D76-F1A5-42AC-BC49-2169527CDDDF}".to_string();
|
||||||
|
iface.friendly_name = Some("tsun0".to_string());
|
||||||
|
iface.description = Some("Wintun Userspace Tunnel".to_string());
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Windows Wintun overlay with description containing Wintun - must be filtered!
|
||||||
|
iface.friendly_name = Some("Local Area Connection 2".to_string());
|
||||||
|
iface.description = Some("Wintun Userspace Tunnel".to_string());
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Docker bridge - must be filtered
|
||||||
|
iface.name = "docker0".to_string();
|
||||||
|
iface.friendly_name = None;
|
||||||
|
iface.description = None;
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Docker network bridge br-* - must be filtered
|
||||||
|
iface.name = "br-4f6d3a9b1c2e".to_string();
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Virtual ethernet veth* - must be filtered
|
||||||
|
iface.name = "veth12345".to_string();
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Libvirt NAT bridge virbr* - must be filtered
|
||||||
|
iface.name = "virbr0".to_string();
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// CNI bridge - must be filtered
|
||||||
|
iface.name = "cni0".to_string();
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Hyper-V / WSL vEthernet switch on Windows - must be filtered
|
||||||
|
iface.name = "{A1B2C3D4-E5F6-7890-1234-567890ABCDEF}".to_string();
|
||||||
|
iface.friendly_name = Some("vEthernet (WSL)".to_string());
|
||||||
|
iface.description = Some("Hyper-V Virtual Ethernet Adapter".to_string());
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Down interface - must be filtered
|
||||||
|
let mut down_iface = netdev::Interface::dummy();
|
||||||
|
down_iface.flags = 0; // Not UP
|
||||||
|
down_iface.name = "eth0".to_string();
|
||||||
|
assert!(!is_candidate_interface(&down_iface));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_candidate_ip() {
|
||||||
|
// Valid LAN IP
|
||||||
|
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
||||||
|
192, 168, 1, 117
|
||||||
|
))));
|
||||||
|
// Valid Tailscale IP
|
||||||
|
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
||||||
|
100, 77, 155, 120
|
||||||
|
))));
|
||||||
|
// Valid Public IP
|
||||||
|
assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
||||||
|
1, 1, 1, 1
|
||||||
|
))));
|
||||||
|
// Valid IPv6 GUA
|
||||||
|
assert!(is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::new(
|
||||||
|
0x2a01, 0x4b00, 0xb8e3, 0x4e00, 0, 0, 0, 1
|
||||||
|
))));
|
||||||
|
|
||||||
|
// Loopback - must be rejected
|
||||||
|
assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
||||||
|
127, 0, 0, 1
|
||||||
|
))));
|
||||||
|
assert!(!is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::LOCALHOST)));
|
||||||
|
|
||||||
|
// Unspecified - must be rejected
|
||||||
|
assert!(!is_candidate_ip(std::net::IpAddr::V4(
|
||||||
|
Ipv4Addr::UNSPECIFIED
|
||||||
|
)));
|
||||||
|
assert!(!is_candidate_ip(std::net::IpAddr::V6(
|
||||||
|
Ipv6Addr::UNSPECIFIED
|
||||||
|
)));
|
||||||
|
|
||||||
|
// Link-local - must be rejected
|
||||||
|
assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new(
|
||||||
|
169, 254, 1, 1
|
||||||
|
))));
|
||||||
|
assert!(!is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::new(
|
||||||
|
0xfe80, 0, 0, 0, 0, 0x1ff, 0xfe00, 1
|
||||||
|
))));
|
||||||
|
|
||||||
|
// Broadcast - must be rejected
|
||||||
|
assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::BROADCAST)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ip_priority_ordering() {
|
||||||
|
let mut iface_lan = netdev::Interface::dummy();
|
||||||
|
iface_lan.flags = 1;
|
||||||
|
iface_lan.gateway = Some(netdev::net::device::NetworkDevice::new());
|
||||||
|
|
||||||
|
let mut iface_vpn = netdev::Interface::dummy();
|
||||||
|
iface_vpn.flags = 1;
|
||||||
|
iface_vpn.name = "tailscale0".to_string();
|
||||||
|
|
||||||
|
let lan_ip = std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 1, 117));
|
||||||
|
let tailscale_ip = std::net::IpAddr::V4(Ipv4Addr::new(100, 77, 155, 120));
|
||||||
|
|
||||||
|
let p_lan = ip_priority(&iface_lan, lan_ip);
|
||||||
|
let p_ts = ip_priority(&iface_vpn, tailscale_ip);
|
||||||
|
|
||||||
|
// LAN IP with gateway should score higher than VPN IP without gateway
|
||||||
|
assert!(p_lan > p_ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,26 +36,146 @@ pub const DEFAULT_MTU: u32 = 1280;
|
|||||||
/// WireGuard packet overhead.
|
/// WireGuard packet overhead.
|
||||||
pub const WIREGUARD_OVERHEAD: u32 = 32;
|
pub const WIREGUARD_OVERHEAD: u32 = 32;
|
||||||
|
|
||||||
/// Discovers local network interface IPs to announce to peers, excluding loopback and overlay interfaces.
|
/// Discovers local network interface IPs to announce to peers, excluding loopback, overlay interfaces, and virtual bridges.
|
||||||
pub fn local_ip_candidates() -> Vec<IpAddr> {
|
pub fn local_ip_candidates() -> Vec<IpAddr> {
|
||||||
let mut ips = Vec::new();
|
let mut candidates = Vec::new();
|
||||||
for iface in netdev::get_interfaces() {
|
for iface in netdev::get_interfaces() {
|
||||||
if iface.is_up() && !iface.is_loopback() {
|
if !is_candidate_interface(&iface) {
|
||||||
let name_lower = iface.name.to_lowercase();
|
|
||||||
if name_lower.starts_with("tsun") || name_lower.contains("tsunagi") {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for ip in iface.ipv4 {
|
for ip in &iface.ipv4 {
|
||||||
let addr = ip.addr();
|
let addr = ip.addr();
|
||||||
if !addr.is_loopback() && !addr.is_unspecified() {
|
if is_candidate_ipv4(addr) {
|
||||||
ips.push(IpAddr::V4(addr));
|
let priority = ip_priority(&iface, IpAddr::V4(addr));
|
||||||
|
candidates.push((priority, IpAddr::V4(addr)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sort descending by priority so the most viable physical / WAN / LAN IP is candidate #0
|
||||||
|
candidates.sort_by_key(|b| std::cmp::Reverse(b.0));
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut ips = Vec::new();
|
||||||
|
for (_, ip) in candidates {
|
||||||
|
if seen.insert(ip) {
|
||||||
|
ips.push(ip);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ips
|
ips
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_candidate_interface(iface: &netdev::Interface) -> bool {
|
||||||
|
if !iface.is_up() || iface.is_loopback() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = iface.name.to_lowercase();
|
||||||
|
let friendly = iface
|
||||||
|
.friendly_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::to_lowercase)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let desc = iface
|
||||||
|
.description
|
||||||
|
.as_deref()
|
||||||
|
.map(str::to_lowercase)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Skip Tsunagi / Wintun overlay interfaces
|
||||||
|
if name.starts_with("tsun")
|
||||||
|
|| name.contains("tsunagi")
|
||||||
|
|| name.contains("wintun")
|
||||||
|
|| friendly.starts_with("tsun")
|
||||||
|
|| friendly.contains("tsunagi")
|
||||||
|
|| friendly.contains("wintun")
|
||||||
|
|| desc.contains("wintun")
|
||||||
|
|| desc.contains("tsunagi")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip container, VM, and virtual bridge interfaces
|
||||||
|
if name.starts_with("docker")
|
||||||
|
|| name.starts_with("br-")
|
||||||
|
|| name.starts_with("veth")
|
||||||
|
|| name.starts_with("virbr")
|
||||||
|
|| name.starts_with("cni")
|
||||||
|
|| name.starts_with("flannel")
|
||||||
|
|| name.starts_with("cali")
|
||||||
|
|| name.starts_with("kube")
|
||||||
|
|| name.starts_with("dummy")
|
||||||
|
|| name.starts_with("lxc")
|
||||||
|
|| name.starts_with("podman")
|
||||||
|
|| friendly.contains("vethernet")
|
||||||
|
|| friendly.contains("vboxnet")
|
||||||
|
|| friendly.contains("vmnet")
|
||||||
|
|| desc.contains("virtualbox")
|
||||||
|
|| desc.contains("hyper-v")
|
||||||
|
|| desc.contains("vmware")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_candidate_ipv4(addr: std::net::Ipv4Addr) -> bool {
|
||||||
|
if addr.is_loopback() || addr.is_unspecified() || addr.is_link_local() || addr.is_broadcast() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if addr.is_documentation() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_candidate_ip(addr: IpAddr) -> bool {
|
||||||
|
match addr {
|
||||||
|
IpAddr::V4(v4) => is_candidate_ipv4(v4),
|
||||||
|
IpAddr::V6(v6) => {
|
||||||
|
if v6.is_loopback() || v6.is_unspecified() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let segments = v6.segments();
|
||||||
|
// Filter link-local fe80::/10
|
||||||
|
if (segments[0] & 0xffc0) == 0xfe80 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn ip_priority(iface: &netdev::Interface, ip: IpAddr) -> u32 {
|
||||||
|
let has_gateway = iface.gateway.is_some();
|
||||||
|
let base = if has_gateway { 100 } else { 0 };
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(v4) => {
|
||||||
|
let octets = v4.octets();
|
||||||
|
// Tailscale: 100.64.0.0/10
|
||||||
|
let is_tailscale = octets[0] == 100 && (octets[1] & 0xc0) == 64;
|
||||||
|
let is_private = v4.is_private();
|
||||||
|
if !is_private && !is_tailscale {
|
||||||
|
// Public / globally routable IP
|
||||||
|
base + 50
|
||||||
|
} else if has_gateway && is_private {
|
||||||
|
// Primary LAN IP with gateway (e.g. 192.168.1.117)
|
||||||
|
base + 40
|
||||||
|
} else if is_tailscale {
|
||||||
|
// Tailscale / CGNAT
|
||||||
|
base + 30
|
||||||
|
} else {
|
||||||
|
// Other private / virtual
|
||||||
|
base + 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IpAddr::V6(v6) => {
|
||||||
|
let segments = v6.segments();
|
||||||
|
let is_global = (segments[0] & 0xe000) == 0x2000;
|
||||||
|
if is_global { base + 45 } else { base + 20 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration of the pure WireGuard plugin.
|
/// Configuration of the pure WireGuard plugin.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WireguardConfig {
|
pub struct WireguardConfig {
|
||||||
@@ -622,9 +742,24 @@ impl IpPlugin for WireguardPlugin {
|
|||||||
|
|
||||||
let validated = WgAnnouncement::decode_and_validate(&capability.data, network, &local_key)?;
|
let validated = WgAnnouncement::decode_and_validate(&capability.data, network, &local_key)?;
|
||||||
|
|
||||||
|
let is_overlay_ip = |ip: &IpAddr| -> bool {
|
||||||
|
let shared = self.worker.lock_shared();
|
||||||
|
shared.networks.values().any(|s| {
|
||||||
|
if let Some(range) = s.ipv4_range {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(v4) => range.contains(*v4),
|
||||||
|
IpAddr::V6(_) => false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let socket_addrs: Vec<SocketAddr> = validated
|
let socket_addrs: Vec<SocketAddr> = validated
|
||||||
.addrs
|
.addrs
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|ip| is_candidate_ip(**ip) && !is_overlay_ip(ip))
|
||||||
.map(|ip| SocketAddr::new(*ip, validated.port))
|
.map(|ip| SocketAddr::new(*ip, validated.port))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -734,3 +869,133 @@ impl IpPlugin for WireguardPlugin {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_candidate_interface_filters_overlay_and_bridges() {
|
||||||
|
let mut iface = netdev::Interface::dummy();
|
||||||
|
iface.flags = 1; // UP
|
||||||
|
|
||||||
|
// Standard Ethernet - should pass
|
||||||
|
iface.name = "eth0".to_string();
|
||||||
|
assert!(is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Standard Wi-Fi - should pass
|
||||||
|
iface.name = "wlan0".to_string();
|
||||||
|
assert!(is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Windows Ethernet with GUID name - should pass
|
||||||
|
iface.name = "{B6149D2C-1234-5678-9ABC-DEF012345678}".to_string();
|
||||||
|
iface.friendly_name = Some("Ethernet".to_string());
|
||||||
|
iface.description = Some("Intel(R) Ethernet Connection".to_string());
|
||||||
|
assert!(is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Linux tsun0 overlay - must be filtered
|
||||||
|
iface.name = "tsun0".to_string();
|
||||||
|
iface.friendly_name = None;
|
||||||
|
iface.description = None;
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Windows Wintun overlay (GUID name with tsun0 friendly name) - must be filtered!
|
||||||
|
iface.name = "{72D85D76-F1A5-42AC-BC49-2169527CDDDF}".to_string();
|
||||||
|
iface.friendly_name = Some("tsun0".to_string());
|
||||||
|
iface.description = Some("Wintun Userspace Tunnel".to_string());
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Windows Wintun overlay with description containing Wintun - must be filtered!
|
||||||
|
iface.friendly_name = Some("Local Area Connection 2".to_string());
|
||||||
|
iface.description = Some("Wintun Userspace Tunnel".to_string());
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Docker bridge - must be filtered
|
||||||
|
iface.name = "docker0".to_string();
|
||||||
|
iface.friendly_name = None;
|
||||||
|
iface.description = None;
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Docker network bridge br-* - must be filtered
|
||||||
|
iface.name = "br-4f6d3a9b1c2e".to_string();
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Virtual ethernet veth* - must be filtered
|
||||||
|
iface.name = "veth12345".to_string();
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Libvirt NAT bridge virbr* - must be filtered
|
||||||
|
iface.name = "virbr0".to_string();
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// CNI bridge - must be filtered
|
||||||
|
iface.name = "cni0".to_string();
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Hyper-V / WSL vEthernet switch on Windows - must be filtered
|
||||||
|
iface.name = "{A1B2C3D4-E5F6-7890-1234-567890ABCDEF}".to_string();
|
||||||
|
iface.friendly_name = Some("vEthernet (WSL)".to_string());
|
||||||
|
iface.description = Some("Hyper-V Virtual Ethernet Adapter".to_string());
|
||||||
|
assert!(!is_candidate_interface(&iface));
|
||||||
|
|
||||||
|
// Down interface - must be filtered
|
||||||
|
let mut down_iface = netdev::Interface::dummy();
|
||||||
|
down_iface.flags = 0; // Not UP
|
||||||
|
down_iface.name = "eth0".to_string();
|
||||||
|
assert!(!is_candidate_interface(&down_iface));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_candidate_ip() {
|
||||||
|
// Valid LAN IP
|
||||||
|
assert!(is_candidate_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 117))));
|
||||||
|
// Valid Tailscale IP
|
||||||
|
assert!(is_candidate_ip(IpAddr::V4(Ipv4Addr::new(
|
||||||
|
100, 77, 155, 120
|
||||||
|
))));
|
||||||
|
// Valid Public IP
|
||||||
|
assert!(is_candidate_ip(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))));
|
||||||
|
// Valid IPv6 GUA
|
||||||
|
assert!(is_candidate_ip(IpAddr::V6(Ipv6Addr::new(
|
||||||
|
0x2a01, 0x4b00, 0xb8e3, 0x4e00, 0, 0, 0, 1
|
||||||
|
))));
|
||||||
|
|
||||||
|
// Loopback - must be rejected
|
||||||
|
assert!(!is_candidate_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||||
|
assert!(!is_candidate_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)));
|
||||||
|
|
||||||
|
// Unspecified - must be rejected
|
||||||
|
assert!(!is_candidate_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
|
||||||
|
assert!(!is_candidate_ip(IpAddr::V6(Ipv6Addr::UNSPECIFIED)));
|
||||||
|
|
||||||
|
// Link-local - must be rejected
|
||||||
|
assert!(!is_candidate_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1))));
|
||||||
|
assert!(!is_candidate_ip(IpAddr::V6(Ipv6Addr::new(
|
||||||
|
0xfe80, 0, 0, 0, 0, 0x1ff, 0xfe00, 1
|
||||||
|
))));
|
||||||
|
|
||||||
|
// Broadcast - must be rejected
|
||||||
|
assert!(!is_candidate_ip(IpAddr::V4(Ipv4Addr::BROADCAST)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ip_priority_ordering() {
|
||||||
|
let mut iface_lan = netdev::Interface::dummy();
|
||||||
|
iface_lan.flags = 1;
|
||||||
|
iface_lan.gateway = Some(netdev::net::device::NetworkDevice::new());
|
||||||
|
|
||||||
|
let mut iface_vpn = netdev::Interface::dummy();
|
||||||
|
iface_vpn.flags = 1;
|
||||||
|
iface_vpn.name = "tailscale0".to_string();
|
||||||
|
|
||||||
|
let lan_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 117));
|
||||||
|
let tailscale_ip = IpAddr::V4(Ipv4Addr::new(100, 77, 155, 120));
|
||||||
|
|
||||||
|
let p_lan = ip_priority(&iface_lan, lan_ip);
|
||||||
|
let p_ts = ip_priority(&iface_vpn, tailscale_ip);
|
||||||
|
|
||||||
|
// LAN IP with gateway should score higher than VPN IP without gateway
|
||||||
|
assert!(p_lan > p_ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,8 @@ impl WireguardLink {
|
|||||||
if msg.len() > MAX_UDP_DATAGRAM_SIZE {
|
if msg.len() > MAX_UDP_DATAGRAM_SIZE {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if confirmed_r.load(Ordering::Acquire) {
|
let is_handshake_init = msg.first() == Some(&1);
|
||||||
|
if confirmed_r.load(Ordering::Acquire) && !is_handshake_init {
|
||||||
let target = match target_r.read() {
|
let target = match target_r.read() {
|
||||||
Ok(g) => *g,
|
Ok(g) => *g,
|
||||||
Err(p) => *p.into_inner(),
|
Err(p) => *p.into_inner(),
|
||||||
@@ -127,16 +128,21 @@ impl WireguardLink {
|
|||||||
/// Updates candidate addresses for this peer.
|
/// Updates candidate addresses for this peer.
|
||||||
pub fn update_candidates(&self, addrs: impl IntoIterator<Item = SocketAddr>) {
|
pub fn update_candidates(&self, addrs: impl IntoIterator<Item = SocketAddr>) {
|
||||||
if let Ok(mut guard) = self.candidates.write() {
|
if let Ok(mut guard) = self.candidates.write() {
|
||||||
for addr in addrs {
|
let new_candidates: Vec<SocketAddr> = addrs
|
||||||
if !guard.contains(&addr) {
|
.into_iter()
|
||||||
guard.push(addr);
|
.filter(|a| !a.ip().is_unspecified())
|
||||||
}
|
.collect();
|
||||||
|
if !new_candidates.is_empty() {
|
||||||
|
*guard = new_candidates;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the target socket address (roaming / NAT hole punch update).
|
/// Updates the target socket address (roaming / NAT hole punch update).
|
||||||
pub fn update_target(&self, new_target: SocketAddr) {
|
pub fn update_target(&self, new_target: SocketAddr) {
|
||||||
|
if new_target.ip().is_unspecified() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.confirmed.store(true, Ordering::Release);
|
self.confirmed.store(true, Ordering::Release);
|
||||||
if let Ok(mut guard) = self.target_addr.write()
|
if let Ok(mut guard) = self.target_addr.write()
|
||||||
&& *guard != new_target
|
&& *guard != new_target
|
||||||
@@ -278,7 +284,7 @@ impl WireguardTransport {
|
|||||||
|
|
||||||
/// Records an observed UDP target address for a peer.
|
/// Records an observed UDP target address for a peer.
|
||||||
pub fn set_peer_addr(&self, peer: EndpointId, addr: SocketAddr) {
|
pub fn set_peer_addr(&self, peer: EndpointId, addr: SocketAddr) {
|
||||||
if !addr.is_ipv4() {
|
if !addr.is_ipv4() || addr.ip().is_unspecified() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Ok(mut guard) = self.peer_addresses.write() {
|
if let Ok(mut guard) = self.peer_addresses.write() {
|
||||||
@@ -293,7 +299,7 @@ impl WireguardTransport {
|
|||||||
if let Ok(mut guard) = self.peer_addresses.write() {
|
if let Ok(mut guard) = self.peer_addresses.write() {
|
||||||
let entry = guard.entry(peer).or_default();
|
let entry = guard.entry(peer).or_default();
|
||||||
for addr in addrs {
|
for addr in addrs {
|
||||||
if addr.is_ipv4() && !entry.contains(&addr) {
|
if addr.is_ipv4() && !addr.ip().is_unspecified() && !entry.contains(&addr) {
|
||||||
entry.push(addr);
|
entry.push(addr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -380,6 +386,9 @@ impl WireguardTransport {
|
|||||||
let mut buf = [0u8; MAX_UDP_DATAGRAM_SIZE];
|
let mut buf = [0u8; MAX_UDP_DATAGRAM_SIZE];
|
||||||
|
|
||||||
while let Ok((len, src_addr)) = socket.recv_from(&mut buf).await {
|
while let Ok((len, src_addr)) = socket.recv_from(&mut buf).await {
|
||||||
|
if src_addr.ip().is_unspecified() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let datagram = Bytes::copy_from_slice(&buf[..len]);
|
let datagram = Bytes::copy_from_slice(&buf[..len]);
|
||||||
|
|
||||||
// 1. Identify which peer this packet comes from.
|
// 1. Identify which peer this packet comes from.
|
||||||
@@ -390,9 +399,7 @@ impl WireguardTransport {
|
|||||||
};
|
};
|
||||||
links_guard
|
links_guard
|
||||||
.values()
|
.values()
|
||||||
.find(|link| {
|
.find(|link| link.target_addr() == src_addr)
|
||||||
link.target_addr() == src_addr || link.target_addr().ip() == src_addr.ip()
|
|
||||||
})
|
|
||||||
.map(|link| link.peer())
|
.map(|link| link.peer())
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
let addrs_guard = match self.peer_addresses.read() {
|
let addrs_guard = match self.peer_addresses.read() {
|
||||||
@@ -401,13 +408,23 @@ impl WireguardTransport {
|
|||||||
};
|
};
|
||||||
addrs_guard
|
addrs_guard
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, addrs)| {
|
.find(|(_, addrs)| addrs.contains(&src_addr))
|
||||||
addrs
|
.map(|(peer, _)| *peer)
|
||||||
|
.or_else(|| {
|
||||||
|
links_guard
|
||||||
|
.values()
|
||||||
|
.find(|link| link.target_addr().ip() == src_addr.ip())
|
||||||
|
.map(|link| link.peer())
|
||||||
|
.or_else(|| {
|
||||||
|
addrs_guard
|
||||||
.iter()
|
.iter()
|
||||||
.any(|a| *a == src_addr || a.ip() == src_addr.ip())
|
.find(|(_, addrs)| {
|
||||||
|
addrs.iter().any(|a| a.ip() == src_addr.ip())
|
||||||
})
|
})
|
||||||
.map(|(peer, _)| *peer)
|
.map(|(peer, _)| *peer)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(peer) = matched_peer else {
|
let Some(peer) = matched_peer else {
|
||||||
|
|||||||
@@ -248,7 +248,25 @@ impl EndpointAdapter {
|
|||||||
let mut overlay_ips = std::collections::HashSet::new();
|
let mut overlay_ips = std::collections::HashSet::new();
|
||||||
for iface in netdev::get_interfaces() {
|
for iface in netdev::get_interfaces() {
|
||||||
let name = iface.name.to_lowercase();
|
let name = iface.name.to_lowercase();
|
||||||
if name.starts_with("tsun") || name.contains("tsunagi") {
|
let friendly = iface
|
||||||
|
.friendly_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::to_lowercase)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let desc = iface
|
||||||
|
.description
|
||||||
|
.as_deref()
|
||||||
|
.map(str::to_lowercase)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if name.starts_with("tsun")
|
||||||
|
|| name.contains("tsunagi")
|
||||||
|
|| name.contains("wintun")
|
||||||
|
|| friendly.starts_with("tsun")
|
||||||
|
|| friendly.contains("tsunagi")
|
||||||
|
|| friendly.contains("wintun")
|
||||||
|
|| desc.contains("wintun")
|
||||||
|
|| desc.contains("tsunagi")
|
||||||
|
{
|
||||||
for ip in iface.ipv4 {
|
for ip in iface.ipv4 {
|
||||||
overlay_ips.insert(std::net::IpAddr::V4(ip.addr()));
|
overlay_ips.insert(std::net::IpAddr::V4(ip.addr()));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user