Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion crates/wind-base/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ 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"
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"
107 changes: 105 additions & 2 deletions crates/wind-base/src/direct.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -98,7 +99,10 @@ pub async fn connect_direct_tcp(addr: SocketAddr, opts: &DirectOutboundOpts) ->
async fn relay_udp_direct(_opts: DirectOutboundOpts, resolver: Arc<dyn Resolver>, 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
Expand All @@ -118,7 +122,8 @@ async fn relay_udp_direct(_opts: DirectOutboundOpts, resolver: Arc<dyn Resolver>
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");
}
}
Expand All @@ -131,6 +136,11 @@ async fn relay_udp_direct(_opts: DirectOutboundOpts, resolver: Arc<dyn Resolver>
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)),
Expand All @@ -156,3 +166,96 @@ async fn relay_udp_direct(_opts: DirectOutboundOpts, resolver: Arc<dyn Resolver>

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<UdpSocket> {
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);
}
}
204 changes: 204 additions & 0 deletions crates/wind-base/tests/udp_relay.rs
Original file line number Diff line number Diff line change
@@ -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<UdpPacket>,
from_relay: mpsc::Receiver<UdpPacket>,
// 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::<UdpPacket>(16);
let (relay_tx, from_relay) = mpsc::channel::<UdpPacket>(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<SocketAddr> {
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");
}
Loading