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