diff --git a/Cargo.lock b/Cargo.lock index ce3c49e..a2d1af0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6059,6 +6059,7 @@ dependencies = [ "async-trait", "bytes", "eyre", + "socket2 0.6.4", "tokio", "tracing", "wind-core", diff --git a/crates/wind-base/Cargo.toml b/crates/wind-base/Cargo.toml index 393321a..ed1379e 100644 --- a/crates/wind-base/Cargo.toml +++ b/crates/wind-base/Cargo.toml @@ -10,6 +10,7 @@ license = "MIT OR Apache-2.0" wind-core = { version = "0.1.1", path = "../wind-core" } tokio = { version = "1", default-features = false, features = ["net"] } +socket2 = "0.6" eyre = "0.6" tracing = "0.1" @@ -17,4 +18,6 @@ bytes = "1" async-trait = "0.1" [dev-dependencies] -tokio = { version = "1", default-features = false, features = ["macros", "rt"] } +wind-core = { version = "0.1.1", path = "../wind-core" } +tokio = { version = "1", default-features = false, features = ["macros", "rt", "net", "time", "sync"] } +bytes = "1" diff --git a/crates/wind-base/src/direct.rs b/crates/wind-base/src/direct.rs index 0410fc5..67c38ec 100644 --- a/crates/wind-base/src/direct.rs +++ b/crates/wind-base/src/direct.rs @@ -4,6 +4,7 @@ use std::{ }; use async_trait::async_trait; +use socket2::{Domain, Socket, Type}; use tokio::net::{TcpSocket, TcpStream, UdpSocket}; use tracing::Instrument; use wind_core::{ @@ -98,7 +99,10 @@ pub async fn connect_direct_tcp(addr: SocketAddr, opts: &DirectOutboundOpts) -> async fn relay_udp_direct(_opts: DirectOutboundOpts, resolver: Arc, udp_stream: UdpStream) -> eyre::Result<()> { let UdpStream { tx, mut rx } = udp_stream; - let relay_socket = Arc::new(UdpSocket::bind("0.0.0.0:0").await?); + let relay_socket = Arc::new(bind_relay_socket()?); + // When the relay socket is dual-stack IPv6, IPv4 targets must be sent to + // their IPv4-mapped form; capture the family once rather than per packet. + let socket_is_v6 = relay_socket.local_addr()?.is_ipv6(); // Both directions are inlined as plain futures rather than `tokio::spawn`ed // — `select!` then implicitly aborts whichever half-loop is still pending @@ -118,7 +122,8 @@ async fn relay_udp_direct(_opts: DirectOutboundOpts, resolver: Arc continue; } }; - if let Err(err) = socket_send.send_to(&pkt.payload, target_sa).await { + let send_target = map_target_for_socket(target_sa, socket_is_v6); + if let Err(err) = socket_send.send_to(&pkt.payload, send_target).await { tracing::warn!(target = %target_sa, error = %err, "UDP send failed"); } } @@ -131,6 +136,11 @@ async fn relay_udp_direct(_opts: DirectOutboundOpts, resolver: Arc match socket_recv.recv_from(&mut buf).await { Ok((len, src_addr)) => { use bytes::Bytes; + // A dual-stack relay socket reports an IPv4 responder as an + // IPv4-mapped IPv6 address (`::ffff:a.b.c.d`). Unmap it so the + // reply the client receives is attributed to the same address + // family as the target it originally sent to. + let src_addr = unmap_source(src_addr); let payload = Bytes::copy_from_slice(&buf[..len]); let pkt = UdpPacket { source: Some(TargetAddr::from(src_addr)), @@ -156,3 +166,96 @@ async fn relay_udp_direct(_opts: DirectOutboundOpts, resolver: Arc Ok(()) } + +/// Bind the local UDP socket used to relay one association's outbound packets. +/// +/// A single association can target a mix of IPv4 and IPv6 hosts, so the socket +/// must be able to reach both families. We bind an IPv6 socket with +/// `IPV6_V6ONLY=false` (dual-stack): IPv6 targets are reached directly and IPv4 +/// targets via their IPv4-mapped form (`::ffff:a.b.c.d`). A host without IPv6 +/// support falls back to an IPv4-only socket, which still reaches IPv4 targets. +/// +/// This mirrors `wind-socks`'s `udp_bind_random_port`. Previously this socket +/// was hard-bound to `0.0.0.0:0` (IPv4 only), so every IPv6 target failed +/// `send_to` with `EAFNOSUPPORT` ("Address family not supported by protocol"). +fn bind_relay_socket() -> std::io::Result { + const V6_UNSPEC: SocketAddr = SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, 0, 0, 0)); + const V4_UNSPEC: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)); + + let socket = Socket::new(Domain::IPV6, Type::DGRAM, None) + .and_then(|s| s.set_only_v6(false).map(|_| s)) + .and_then(|s| s.bind(&V6_UNSPEC.into()).map(|_| s)) + .or_else(|_| Socket::new(Domain::IPV4, Type::DGRAM, None).and_then(|s| s.bind(&V4_UNSPEC.into()).map(|_| s)))?; + socket.set_nonblocking(true)?; + UdpSocket::from_std(socket.into()) +} + +/// Rewrite an outbound target into the form sendable on the relay socket. +/// +/// On a dual-stack IPv6 socket, an IPv4 destination must be expressed as +/// IPv4-mapped IPv6; passing a bare `SocketAddr::V4` to `send_to` would itself +/// fail with `EAFNOSUPPORT`. IPv6 targets, and all targets on an IPv4-only +/// socket, are sent unchanged. +fn map_target_for_socket(target: SocketAddr, socket_is_v6: bool) -> SocketAddr { + match target { + SocketAddr::V4(v4) if socket_is_v6 => SocketAddr::V6(SocketAddrV6::new(v4.ip().to_ipv6_mapped(), v4.port(), 0, 0)), + _ => target, + } +} + +/// Undo IPv4-mapped IPv6 (`::ffff:a.b.c.d`) so reply packets are attributed to +/// the responder's true address family. Real IPv6 and IPv4 sources pass +/// through unchanged. +fn unmap_source(addr: SocketAddr) -> SocketAddr { + match addr { + SocketAddr::V6(v6) => match v6.ip().to_ipv4_mapped() { + Some(v4) => SocketAddr::V4(SocketAddrV4::new(v4, v6.port())), + None => addr, + }, + _ => addr, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn v4_target_is_mapped_only_on_dual_stack_socket() { + let v4: SocketAddr = "192.0.2.1:53".parse().unwrap(); + // Dual-stack v6 socket: must become IPv4-mapped v6, else EAFNOSUPPORT. + let mapped = map_target_for_socket(v4, true); + assert_eq!(mapped, "[::ffff:192.0.2.1]:53".parse().unwrap()); + assert!(mapped.is_ipv6()); + // IPv4-only socket: sent unchanged. + assert_eq!(map_target_for_socket(v4, false), v4); + } + + #[test] + fn v6_target_is_never_rewritten() { + // The exact target family from the bug report. + let v6: SocketAddr = "[2404:3fc0:2:101::671c:3697]:27019".parse().unwrap(); + assert_eq!(map_target_for_socket(v6, true), v6); + assert_eq!(map_target_for_socket(v6, false), v6); + } + + #[test] + fn unmap_restores_v4_from_mapped_v6() { + let mapped: SocketAddr = "[::ffff:192.0.2.1]:53".parse().unwrap(); + assert_eq!(unmap_source(mapped), "192.0.2.1:53".parse().unwrap()); + } + + #[test] + fn unmap_leaves_real_v6_and_v4_untouched() { + let v6: SocketAddr = "[2001:db8::1]:443".parse().unwrap(); + assert_eq!(unmap_source(v6), v6); + let v4: SocketAddr = "10.0.0.1:80".parse().unwrap(); + assert_eq!(unmap_source(v4), v4); + } + + #[test] + fn map_then_unmap_roundtrips_v4() { + let v4: SocketAddr = "203.0.113.7:9000".parse().unwrap(); + assert_eq!(unmap_source(map_target_for_socket(v4, true)), v4); + } +} diff --git a/crates/wind-base/tests/udp_relay.rs b/crates/wind-base/tests/udp_relay.rs new file mode 100644 index 0000000..ba112e6 --- /dev/null +++ b/crates/wind-base/tests/udp_relay.rs @@ -0,0 +1,204 @@ +//! End-to-end tests for the direct UDP relay's dual-stack behaviour. +//! +//! These drive the public `OutboundAction::handle_udp` entrypoint exactly as +//! the TUIC/SOCKS inbounds do — feeding `UdpPacket`s in through the association +//! channel and reading the replies back out — against real loopback UDP echo +//! servers. +//! +//! They are the regression guard for the fix that made the relay socket +//! dual-stack. Previously it was hard-bound to `0.0.0.0:0` (IPv4 only), so an +//! IPv6 target failed `send_to` with `EAFNOSUPPORT` ("Address family not +//! supported by protocol") and the reply never arrived — i.e. before the fix +//! `ipv6_target_roundtrips` would time out. + +use std::{net::SocketAddr, sync::Arc, time::Duration}; + +use bytes::Bytes; +use tokio::{net::UdpSocket, sync::mpsc, time::timeout}; +use wind_base::direct::{DirectOutbound, DirectOutboundOpts}; +use wind_core::{ + OutboundAction, StackPrefer, SystemResolver, + types::TargetAddr, + udp::{UdpPacket, UdpStream}, +}; + +/// Generous upper bound — a loopback round trip is sub-millisecond; this only +/// exists so a dropped packet fails loudly instead of hanging the suite. +const REPLY_TIMEOUT: Duration = Duration::from_secs(5); + +/// A running relay association: push packets toward targets via `to_relay`, +/// read replies from targets via `from_relay`. +struct RelayHarness { + to_relay: mpsc::Sender, + from_relay: mpsc::Receiver, + // Held only to keep the association task alive for the test's duration; it + // ends when `to_relay` drops (closing the relay's `rx`) or the test's + // runtime shuts down. + _task: tokio::task::JoinHandle<()>, +} + +/// Spawn a `DirectOutbound` UDP relay and return the client-side channel ends. +/// +/// Two crossed channels model the association, mirroring how the dispatcher +/// wires an inbound to an outbound: +/// client → relay (packets to send to targets) +/// relay → client (packets received back from targets) +fn spawn_relay() -> RelayHarness { + let resolver = Arc::new(SystemResolver::new(StackPrefer::V4first)); + let outbound = DirectOutbound::new( + DirectOutboundOpts { + bind_ipv4: None, + bind_ipv6: None, + bind_device: None, + }, + resolver, + ); + + let (to_relay, relay_rx) = mpsc::channel::(16); + let (relay_tx, from_relay) = mpsc::channel::(16); + let stream = UdpStream { + tx: relay_tx, + rx: relay_rx, + }; + + let task = tokio::spawn(async move { + let _ = outbound.handle_udp(stream).await; + }); + + RelayHarness { + to_relay, + from_relay, + _task: task, + } +} + +/// Bind a UDP echo server to `bind`, echoing each datagram back to its sender +/// until the socket is dropped. Returns the actual bound address. +async fn spawn_echo_server(bind: &str) -> std::io::Result { + let sock = UdpSocket::bind(bind).await?; + let addr = sock.local_addr()?; + tokio::spawn(async move { + let mut buf = vec![0u8; 2048]; + while let Ok((n, from)) = sock.recv_from(&mut buf).await { + if sock.send_to(&buf[..n], from).await.is_err() { + break; + } + } + }); + Ok(addr) +} + +/// Send `payload` to `target` through the relay and await the single reply. +async fn roundtrip(h: &mut RelayHarness, target: TargetAddr, payload: &'static [u8]) -> UdpPacket { + h.to_relay + .send(UdpPacket { + source: None, + target, + payload: Bytes::from_static(payload), + }) + .await + .expect("relay accepts the outbound packet"); + + timeout(REPLY_TIMEOUT, h.from_relay.recv()) + .await + .expect("relay produced a reply before the timeout") + .expect("relay reply channel stayed open") +} + +#[tokio::test] +async fn ipv6_target_roundtrips() { + // On a host without IPv6 loopback the dual-stack relay falls back to an + // IPv4-only socket and genuinely cannot reach `::1`; skip rather than fail. + let Ok(echo) = spawn_echo_server("[::1]:0").await else { + eprintln!("skipping ipv6_target_roundtrips: no IPv6 loopback available"); + return; + }; + + let mut h = spawn_relay(); + let reply = roundtrip(&mut h, TargetAddr::from(echo), b"hello-v6").await; + + assert_eq!(reply.payload, Bytes::from_static(b"hello-v6"), "payload echoed back"); + // The responder is the IPv6 echo server; its address must surface as IPv6. + assert_eq!( + reply.target, + TargetAddr::from(echo), + "reply attributed to the IPv6 responder, got {:?}", + reply.target + ); + assert!(matches!(reply.target, TargetAddr::IPv6(..))); +} + +#[tokio::test] +async fn ipv4_target_roundtrips_with_unmapped_source() { + let echo = spawn_echo_server("127.0.0.1:0").await.expect("bind IPv4 echo server"); + + let mut h = spawn_relay(); + let reply = roundtrip(&mut h, TargetAddr::from(echo), b"hello-v4").await; + + assert_eq!(reply.payload, Bytes::from_static(b"hello-v4"), "payload echoed back"); + // Crux of the dual-stack fix: the relay reaches 127.0.0.1 via its + // IPv4-mapped form, but the reply source must be unmapped back to plain + // IPv4 — otherwise the client sees `::ffff:127.0.0.1` and cannot match the + // reply to the IPv4 target it sent to. + assert_eq!( + reply.target, + TargetAddr::from(echo), + "reply source unmapped to IPv4, got {:?}", + reply.target + ); + assert!(matches!(reply.target, TargetAddr::IPv4(..))); +} + +#[tokio::test] +async fn single_association_relays_both_families() { + let v4_echo = spawn_echo_server("127.0.0.1:0").await.expect("bind IPv4 echo server"); + let Ok(v6_echo) = spawn_echo_server("[::1]:0").await else { + eprintln!("skipping single_association_relays_both_families: no IPv6 loopback available"); + return; + }; + + let mut h = spawn_relay(); + + // Both targets travel over the SAME association — i.e. the same single + // dual-stack relay socket must reach IPv4 and IPv6 hosts interchangeably. + h.to_relay + .send(UdpPacket { + source: None, + target: TargetAddr::from(v4_echo), + payload: Bytes::from_static(b"to-v4"), + }) + .await + .unwrap(); + h.to_relay + .send(UdpPacket { + source: None, + target: TargetAddr::from(v6_echo), + payload: Bytes::from_static(b"to-v6"), + }) + .await + .unwrap(); + + // Replies may arrive in either order; match on payload. + let mut got_v4 = false; + let mut got_v6 = false; + for _ in 0..2 { + let reply = timeout(REPLY_TIMEOUT, h.from_relay.recv()) + .await + .expect("reply before the timeout") + .expect("reply channel stayed open"); + match reply.payload.as_ref() { + b"to-v4" => { + assert_eq!(reply.target, TargetAddr::from(v4_echo)); + assert!(matches!(reply.target, TargetAddr::IPv4(..))); + got_v4 = true; + } + b"to-v6" => { + assert_eq!(reply.target, TargetAddr::from(v6_echo)); + assert!(matches!(reply.target, TargetAddr::IPv6(..))); + got_v6 = true; + } + other => panic!("unexpected echo payload: {other:?}"), + } + } + assert!(got_v4 && got_v6, "both address families round-tripped on one relay socket"); +}