From aed7eef45489e33184552852d1ca4f9ae6066979 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Fri, 14 Aug 2026 11:49:05 +0700 Subject: [PATCH] fix(server): mark a mapped listener with the IPv4 option --- crates/irtt-server/AGENTS.md | 15 ++++- crates/irtt-server/src/runtime.rs | 6 +- crates/irtt-server/src/socket_options.rs | 65 ++++++++++++++++++++- crates/irtt-server/tests/wildcard_source.rs | 14 ++++- 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/crates/irtt-server/AGENTS.md b/crates/irtt-server/AGENTS.md index 919ca96..1f9b178 100644 --- a/crates/irtt-server/AGENTS.md +++ b/crates/irtt-server/AGENTS.md @@ -258,7 +258,20 @@ session policy lives. Whether an unappliable zero is nevertheless safe is the runtime's decision, made from what this server itself applied. - The option itself comes from `socket2`'s safe API, chosen by the listener's - bound address family rather than the peer's. The crate forbids unsafe code; + bound address family rather than the peer's. **On Linux an IPv4-mapped bound + address is an IPv4 listener**, whatever shape it wears: the socket is + `AF_INET6`, but the kernel binds it to the IPv4 side and `IPV6_TCLASS` does not + reach the packets it emits — it leaves their TOS at zero and the negotiated + marking is lost, while `IP_TOS` on that same socket marks them. macOS rejects + `IP_TOS` on an `AF_INET6` socket outright and marks nothing either way, so it + keeps the IPv6 option: reading a mapped bind as IPv4 there would turn an + unmarked reply into a *dropped* one, because an unappliable marking stops the + send. Verify a platform before extending that list. +- A genuinely dual-stack `[::]` listener still emits **unmarked** IPv4-mapped + replies, and this is known. It is not fixed by choosing the option per reply, + for the macOS reason above, and it predates wildcard source selection. Leave it + until it is worth its own slice, and do not "fix" it by weakening the rule that + an unappliable marking stops the send. The crate forbids unsafe code; there is no raw `setsockopt` and no `sendmsg`/`cmsg` machinery. Keep the target lists identical to the pinned `socket2` version's own gates — check its source rather than copying another crate's older matrix. diff --git a/crates/irtt-server/src/runtime.rs b/crates/irtt-server/src/runtime.rs index ab82a92..7c6e68f 100644 --- a/crates/irtt-server/src/runtime.rs +++ b/crates/irtt-server/src/runtime.rs @@ -12,7 +12,9 @@ use thiserror::Error; use tokio::{net::UdpSocket, time::MissedTickBehavior}; use crate::{ - socket_io, socket_options::set_reply_traffic_class, ServerConfig, ServerCore, ServerError, + socket_io, + socket_options::{marks_with_ipv4_option, set_reply_traffic_class}, + ServerConfig, ServerCore, ServerError, }; /// Fixed transport receive capacity. @@ -123,7 +125,7 @@ impl Server { socket, core: ServerCore::new(config), recv_buffer: vec![0; RECEIVE_BUFFER_LEN], - listener_is_ipv4: addr.is_ipv4(), + listener_is_ipv4: marks_with_ipv4_option(addr), select_reply_source, socket_is_marked: false, }) diff --git a/crates/irtt-server/src/socket_options.rs b/crates/irtt-server/src/socket_options.rs index 8709181..3a40232 100644 --- a/crates/irtt-server/src/socket_options.rs +++ b/crates/irtt-server/src/socket_options.rs @@ -10,10 +10,40 @@ //! there is no raw `setsockopt` or per-packet control message anywhere in the //! server. -use std::io; +use std::{ + io, + net::{IpAddr, SocketAddr}, +}; use socket2::SockRef; +/// Whether this listener's replies are marked with the IPv4 TOS option rather +/// than the IPv6 Traffic Class one. +/// +/// The listener's own bound family answers this, not the peer's — one socket, +/// one answer. The exception is an IPv4-mapped bound address, where the socket +/// is `AF_INET6` but every packet it emits is IPv4, and the platforms disagree +/// about it: +/// +/// - **Linux** binds such a socket to the IPv4 side. `IPV6_TCLASS` does not +/// reach the packets it sends — their TOS stays zero and a negotiated marking +/// is silently lost — while `IP_TOS` on that same socket marks them. So a +/// mapped bind is read as the IPv4 listener it is. +/// - **macOS** rejects `IP_TOS` on an `AF_INET6` socket with `EINVAL` whatever +/// it is bound to, and marks nothing through either option. Reading a mapped +/// bind as IPv4 there would turn an unmarked reply into a *dropped* one, since +/// an unappliable marking stops the send — worse than the marking simply not +/// arriving. It keeps the IPv6 option and its existing behavior. (The mapped +/// *wildcard* never reaches this at all: macOS normalizes that bind to `[::]`.) +/// - **FreeBSD** refuses a mapped bind under its default `IPV6_V6ONLY`, so the +/// case does not arise. +pub(crate) fn marks_with_ipv4_option(addr: SocketAddr) -> bool { + match addr.ip() { + IpAddr::V4(_) => true, + IpAddr::V6(address) => cfg!(target_os = "linux") && address.to_ipv4_mapped().is_some(), + } +} + /// Applies `traffic_class` to a socket bound in the given family. /// /// `is_ipv4` is the *listener's* family, not the peer's. @@ -119,3 +149,36 @@ fn unsupported_traffic_class(_traffic_class: u32, option: &'static str) -> io::R format!("{option} are unsupported on this target"), )) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Which listeners mark with the IPv4 option. + /// + /// The mapped forms are the point: such a socket is `AF_INET6` but sends + /// IPv4, and `IPV6_TCLASS` does not reach those packets. Linux marks them + /// through `IP_TOS`; the platforms that would reject that keep the IPv6 + /// option, because an unappliable marking stops the send and a dropped + /// reply is worse than an unmarked one. + #[test] + fn a_mapped_listener_marks_with_the_ipv4_option_where_that_option_works() { + let mapped = cfg!(target_os = "linux"); + + for (addr, is_ipv4) in [ + ("0.0.0.0:2112", true), + ("127.0.0.1:2112", true), + ("[::ffff:0.0.0.0]:2112", mapped), + ("[::ffff:127.0.0.1]:2112", mapped), + ("[::]:2112", false), + ("[::1]:2112", false), + ("[2001:db8::1]:2112", false), + ] { + assert_eq!( + marks_with_ipv4_option(addr.parse().unwrap()), + is_ipv4, + "{addr} would mark with the wrong option" + ); + } + } +} diff --git a/crates/irtt-server/tests/wildcard_source.rs b/crates/irtt-server/tests/wildcard_source.rs index 50b7347..b772750 100644 --- a/crates/irtt-server/tests/wildcard_source.rs +++ b/crates/irtt-server/tests/wildcard_source.rs @@ -96,7 +96,19 @@ async fn an_ipv4_mapped_wildcard_listener_replies_from_the_requested_address() { let mut client = connected_client(target).await; assert_started(&mut client, target).await; - echo_once(&mut client).await; + let meta = echo_once(&mut client).await; + // A mapped listener is `AF_INET6` but emits IPv4, and the IPv6 Traffic + // Class option does not reach those packets — it leaves their TOS at + // zero. Only the IPv4 option marks them, so this asserts the listener + // was read as the IPv4 one it is. + if cfg!(target_os = "linux") { + assert_eq!( + meta.traffic_class, + Some(EF_TRAFFIC_CLASS), + "a mapped listener's reply still carries its session's marking" + ); + assert_eq!(meta.dscp, Some(EF_DSCP)); + } assert!(matches!( client.close().await.unwrap().as_slice(), [ClientEvent::SessionClosed { .. }]