diff --git a/crates/irtt-server/src/runtime.rs b/crates/irtt-server/src/runtime.rs index ad740e4..ab82a92 100644 --- a/crates/irtt-server/src/runtime.rs +++ b/crates/irtt-server/src/runtime.rs @@ -1,6 +1,11 @@ //! Tokio UDP orchestration around [`ServerCore`](crate::ServerCore). -use std::{future::Future, io, net::SocketAddr, time::Duration}; +use std::{ + future::Future, + io, + net::{IpAddr, SocketAddr}, + time::Duration, +}; use socket2::SockRef; use thiserror::Error; @@ -104,7 +109,7 @@ impl Server { let addr = socket .local_addr() .map_err(|source| ServerRuntimeError::LocalAddr { source })?; - let select_reply_source = addr.ip().is_unspecified(); + let select_reply_source = is_wildcard(addr); if select_reply_source { if !socket_io::SUPPORTED { @@ -243,6 +248,30 @@ impl Server { } } +/// Whether a bound address names no particular local address, and so owes every +/// reply an explicitly selected source. +/// +/// `0.0.0.0` and `[::]` are the obvious forms. The third is the IPv4-mapped +/// unspecified address, `[::ffff:0.0.0.0]`: Linux accepts it as an IPv4 wildcard +/// bind and reports it back verbatim from `getsockname`, so asking +/// `is_unspecified` alone would take a working wildcard listener for an explicit +/// one and answer its requests from whatever source the routing table picked. +/// (macOS normalizes that bind to `[::]` and never reaches the second test.) +/// +/// A mapped address that names an actual host address — `[::ffff:127.0.0.1]` — +/// is explicit, and stays on the plain path. +fn is_wildcard(addr: SocketAddr) -> bool { + match addr.ip() { + IpAddr::V4(address) => address.is_unspecified(), + IpAddr::V6(address) => { + address.is_unspecified() + || address + .to_ipv4_mapped() + .is_some_and(|mapped| mapped.is_unspecified()) + } + } +} + /// Whether a reply may still be sent after its traffic class could not be /// applied. /// @@ -479,6 +508,32 @@ mod tests { ServerConfig::default().with_min_send_interval(Duration::ZERO) } + /// Which bound addresses owe their replies a selected source. + /// + /// The IPv4-mapped unspecified address is the one that is not obvious, and + /// it is not hypothetical: Linux accepts `[::ffff:0.0.0.0]` as an IPv4 + /// wildcard bind and reports it back unchanged, so missing it would leave a + /// working wildcard listener on the plain send path. A mapped address + /// naming a real host address is explicit and must stay there. + #[test] + fn a_bound_address_naming_no_particular_local_address_is_a_wildcard() { + for (addr, wildcard) in [ + ("0.0.0.0:2112", true), + ("[::]:2112", true), + ("[::ffff:0.0.0.0]:2112", true), + ("127.0.0.1:2112", false), + ("192.0.2.10:2112", false), + ("[::1]:2112", false), + ("[::ffff:127.0.0.1]:2112", false), + ] { + assert_eq!( + is_wildcard(addr.parse().unwrap()), + wildcard, + "{addr} classified wrongly" + ); + } + } + /// The rule that decides what an unappliable traffic class means, which no /// normal interface can reach: it needs a host that refuses the socket /// option, and the hosts these tests run on do not. diff --git a/crates/irtt-server/src/socket_io/ancillary.rs b/crates/irtt-server/src/socket_io/ancillary.rs index 074066e..a75ffe8 100644 --- a/crates/irtt-server/src/socket_io/ancillary.rs +++ b/crates/irtt-server/src/socket_io/ancillary.rs @@ -23,7 +23,7 @@ use std::{ io::{self, IoSlice, IoSliceMut}, - net::{Ipv4Addr, Ipv6Addr, SocketAddr}, + net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV6}, os::fd::AsRawFd, }; @@ -218,6 +218,13 @@ fn send_once( }, ipi6_ifindex: interface_index, }; + // The destination has to be stated in the same family the control + // message is: an IPv6 source with an `AF_INET` destination is a + // rejected send, not a mixed-family one. The two can disagree — + // macOS reports an IPv4 peer for a listener bound to the IPv4-mapped + // wildcard while still delivering IPv6 packet info for it — and the + // peer identity the core sees stays exactly as it was received. + let destination = SockaddrStorage::from(as_ipv6_endpoint(peer)); sendmsg( socket.as_raw_fd(), &iov, @@ -231,6 +238,18 @@ fn send_once( Ok(sent) } +/// The same endpoint expressed in IPv6, mapping an IPv4 peer. +/// +/// A datagram sent to an IPv4-mapped destination from an `AF_INET6` socket goes +/// out as ordinary IPv4 to that address; this changes how the endpoint is +/// spelled for one `sendmsg`, not what reaches the client. +fn as_ipv6_endpoint(peer: SocketAddr) -> SocketAddrV6 { + match peer { + SocketAddr::V6(peer) => peer, + SocketAddr::V4(peer) => SocketAddrV6::new(peer.ip().to_ipv6_mapped(), peer.port(), 0, 0), + } +} + /// `IP_PKTINFO` on a send reads only `ipi_spec_dst`, the source address, and /// `ipi_ifindex`. `ipi_addr` is a receive-only field and is left zero. /// diff --git a/crates/irtt-server/tests/wildcard_source.rs b/crates/irtt-server/tests/wildcard_source.rs index 2789dda..50b7347 100644 --- a/crates/irtt-server/tests/wildcard_source.rs +++ b/crates/irtt-server/tests/wildcard_source.rs @@ -71,6 +71,43 @@ async fn a_wildcard_ipv4_listener_replies_from_the_requested_address() { .expect("the wildcard IPv4 source-selection test exceeded its bounded runtime"); } +#[tokio::test(flavor = "current_thread")] +async fn an_ipv4_mapped_wildcard_listener_replies_from_the_requested_address() { + // `[::ffff:0.0.0.0]` is a wildcard that does not look like one. Linux + // accepts it as an IPv4 wildcard bind and `getsockname` reports it back + // unchanged, so a listener that only recognized `0.0.0.0` and `[::]` would + // take this for an explicit address and answer from a routing-selected + // source. macOS normalizes the bind to `[::]`, where this is simply the + // IPv6 wildcard case again. + timeout(Duration::from_secs(5), async { + let server = match Server::bind("[::ffff:0.0.0.0]:0".parse().unwrap(), unthrottled()).await + { + Ok(server) => server, + // FreeBSD refuses a mapped bind under its default `IPV6_V6ONLY`. + Err(ServerRuntimeError::Bind { source, .. }) if is_family_unavailable(&source) => { + eprintln!("skipping IPv4-mapped wildcard test: the bind is unavailable here"); + return; + } + Err(error) => panic!("unexpected IPv4-mapped wildcard bind failure: {error}"), + }; + let port = server.local_addr().unwrap().port(); + let target = SocketAddr::from((secondary_ipv4_loopback(), port)); + let running = spawn(server); + + let mut client = connected_client(target).await; + assert_started(&mut client, target).await; + echo_once(&mut client).await; + assert!(matches!( + client.close().await.unwrap().as_slice(), + [ClientEvent::SessionClosed { .. }] + )); + + running.stop().await; + }) + .await + .expect("the IPv4-mapped wildcard test exceeded its bounded runtime"); +} + #[tokio::test(flavor = "current_thread")] async fn a_wildcard_ipv6_listener_replies_from_the_requested_address() { timeout(Duration::from_secs(5), async {