feat(server): pin wildcard reply source address #85
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { .. }]
|
||||||
|
|||||||
Reference in new issue
Block a user