feat(server): pin wildcard reply source address #85

Merged
Lochnair merged 5 commits from feat/server-wildcard-source-selection into main 2026-08-14 05:11:17 +00:00
4 changed files with 95 additions and 5 deletions
Showing only changes of commit aed7eef454 - Show all commits

No files matched your search

+14 -1
View File
@@ -258,7 +258,20 @@ session policy lives.
Whether an unappliable zero is nevertheless safe is the runtime's decision, Whether an unappliable zero is nevertheless safe is the runtime's decision,
made from what this server itself applied. made from what this server itself applied.
- The option itself comes from `socket2`'s safe API, chosen by the listener's - 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 there is no raw `setsockopt` and no `sendmsg`/`cmsg` machinery. Keep the
target lists identical to the pinned `socket2` version's own gates — check target lists identical to the pinned `socket2` version's own gates — check
its source rather than copying another crate's older matrix. its source rather than copying another crate's older matrix.
+4 -2
View File
@@ -12,7 +12,9 @@ use thiserror::Error;
use tokio::{net::UdpSocket, time::MissedTickBehavior}; use tokio::{net::UdpSocket, time::MissedTickBehavior};
use crate::{ 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. /// Fixed transport receive capacity.
4
@@ -123,7 +125,7 @@ impl Server {
socket, socket,
core: ServerCore::new(config), core: ServerCore::new(config),
recv_buffer: vec![0; RECEIVE_BUFFER_LEN], recv_buffer: vec![0; RECEIVE_BUFFER_LEN],
listener_is_ipv4: addr.is_ipv4(), listener_is_ipv4: marks_with_ipv4_option(addr),
select_reply_source, select_reply_source,
socket_is_marked: false, socket_is_marked: false,
}) })
+64 -1
View File
@@ -10,10 +10,40 @@
//! there is no raw `setsockopt` or per-packet control message anywhere in the //! there is no raw `setsockopt` or per-packet control message anywhere in the
//! server. //! server.
use std::io; use std::{
io,
net::{IpAddr, SocketAddr},
};
use socket2::SockRef; 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. /// Applies `traffic_class` to a socket bound in the given family.
/// ///
/// `is_ipv4` is the *listener's* family, not the peer's. /// `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"), 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"
);
}
}
}
+13 -1
View File
@@ -96,7 +96,19 @@ async fn an_ipv4_mapped_wildcard_listener_replies_from_the_requested_address() {
let mut client = connected_client(target).await; let mut client = connected_client(target).await;
assert_started(&mut 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!( assert!(matches!(
client.close().await.unwrap().as_slice(), client.close().await.unwrap().as_slice(),
[ClientEvent::SessionClosed { .. }] [ClientEvent::SessionClosed { .. }]