fix(server): pin the reply source, not the route

This commit is contained in:
Nils Andreas Svee committed 2026-08-14 11:31:10 +07:00
1 parent e154508cb1
commit 2d88939ed4
3 files changed
+72 -6

No files matched your search

+6
View File
@@ -100,6 +100,12 @@ address fails invisibly, as loss.
- The receive and send structures are not interchangeable, and the BSD IPv4 - The receive and send structures are not interchangeable, and the BSD IPv4
options are not Linux's under other names. Build each direction's control options are not Linux's under other names. Build each direction's control
message explicitly rather than echoing a received one back. message explicitly rather than echoing a received one back.
- **Pin the source address, not the route.** What the protocol requires is the
address a reply leaves from; which interface carries it is the routing table's
business. Naming the arrival interface on a send would lose replies on a host
whose path back to the peer leaves by another one. The single exception is a
link-local source, which is not unique across interfaces and must name its
own — every other source, IPv4 included, goes out with no interface set.
- A send failure is per-packet loss, exactly as an ordinary `send_to` failure - A send failure is per-packet loss, exactly as an ordinary `send_to` failure
is. It never terminates the listener and never rolls back core state. is. It never terminates the listener and never rolls back core state.
+4 -2
View File
@@ -48,8 +48,10 @@ pub(crate) enum ReplySource {
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))]
V4 { address: std::net::Ipv4Addr }, V4 { address: std::net::Ipv4Addr },
/// The IPv6 address this request was addressed to, and the interface it /// The IPv6 address this request was addressed to, and the interface it
/// arrived on. The interface is part of the identity of a scoped address, /// arrived on. The interface is part of the identity of a *scoped* address,
/// so it is carried rather than rediscovered. /// so it is carried rather than rediscovered — and it is used on the send
/// only where the source address is one, so that a reply is never forced
/// out of an interface the route to the peer does not use.
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))]
V6 { V6 {
address: std::net::Ipv6Addr, address: std::net::Ipv6Addr,
+62 -4
View File
@@ -209,14 +209,13 @@ fn send_once(
address, address,
interface_index, interface_index,
} => { } => {
// For a send, `in6_pktinfo` names the source address and the // For a send, `in6_pktinfo` names the source address and, where it
// interface to leave by — the interface being what makes a scoped // is needed, the interface to leave by.
// address unambiguous.
let info = libc::in6_pktinfo { let info = libc::in6_pktinfo {
ipi6_addr: libc::in6_addr { ipi6_addr: libc::in6_addr {
s6_addr: address.octets(), s6_addr: address.octets(),
}, },
ipi6_ifindex: interface_index, ipi6_ifindex: egress_interface_index(address, interface_index),
}; };
// The destination has to be stated in the same family the control // The destination has to be stated in the same family the control
// message is: an IPv6 source with an `AF_INET` destination is a // message is: an IPv6 source with an `AF_INET` destination is a
@@ -238,6 +237,28 @@ fn send_once(
Ok(sent) Ok(sent)
} }
/// The interface a reply must leave by, which is usually none of our business.
///
/// A link-local source address is not unique across a host's interfaces, so a
/// reply carrying one has to name the interface it belongs to. Every other
/// source — global, unique-local, loopback, IPv4-mapped — identifies itself
/// without help.
///
/// Pinning the arrival interface for those would only add a way for a reply to
/// fail: on a host whose route back to the peer leaves by a different interface
/// than the request arrived on, forcing the ingress interface can make the send
/// fail outright, losing a reply whose source address was perfectly valid. What
/// the protocol requires is the source *address*; the route to the peer is the
/// routing table's business. The IPv4 path makes the same choice for the same
/// reason.
fn egress_interface_index(address: Ipv6Addr, interface_index: u32) -> u32 {
if address.is_unicast_link_local() {
interface_index
} else {
0
}
}
/// The same endpoint expressed in IPv6, mapping an IPv4 peer. /// The same endpoint expressed in IPv6, mapping an IPv4 peer.
/// ///
/// A datagram sent to an IPv4-mapped destination from an `AF_INET6` socket goes /// A datagram sent to an IPv4-mapped destination from an `AF_INET6` socket goes
@@ -316,3 +337,40 @@ impl ControlBuffer {
&mut self.0 &mut self.0
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// Which reply sources need their interface named, which no normal
/// interface can show: it takes a host whose route back to a peer leaves by
/// a different interface than the request arrived on, and a test host has
/// one loopback.
#[test]
fn only_a_link_local_source_pins_the_egress_interface() {
const ARRIVED_ON: u32 = 7;
for (address, expected, why) in [
(
"fe80::1",
ARRIVED_ON,
"a link-local source needs its link named",
),
(
"fe80::7858:baff:fe91:183d",
ARRIVED_ON,
"and so does any other one",
),
("2001:db8::1", 0, "a global source identifies itself"),
("fd00::1", 0, "so does a unique-local one"),
("::1", 0, "and loopback"),
("::ffff:127.0.0.2", 0, "and an IPv4-mapped destination"),
] {
assert_eq!(
egress_interface_index(address.parse().unwrap(), ARRIVED_ON),
expected,
"{address}: {why}"
);
}
}
}