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
3 changed files with 114 additions and 3 deletions
Showing only changes of commit e154508cb1 - Show all commits

No files matched your search

+57 -2
View File
@@ -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;
4
@@ -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.
+20 -1
View File
1
@@ -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 {