From 2d88939ed47130862635fb7952f1ce77c313c22d Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Fri, 14 Aug 2026 11:31:10 +0700 Subject: [PATCH] fix(server): pin the reply source, not the route --- crates/irtt-server/AGENTS.md | 6 ++ crates/irtt-server/src/socket_io.rs | 6 +- crates/irtt-server/src/socket_io/ancillary.rs | 66 +++++++++++++++++-- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/crates/irtt-server/AGENTS.md b/crates/irtt-server/AGENTS.md index 6cd962d..919ca96 100644 --- a/crates/irtt-server/AGENTS.md +++ b/crates/irtt-server/AGENTS.md @@ -100,6 +100,12 @@ address fails invisibly, as loss. - 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 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 is. It never terminates the listener and never rolls back core state. diff --git a/crates/irtt-server/src/socket_io.rs b/crates/irtt-server/src/socket_io.rs index 6e1e191..912801a 100644 --- a/crates/irtt-server/src/socket_io.rs +++ b/crates/irtt-server/src/socket_io.rs @@ -48,8 +48,10 @@ pub(crate) enum ReplySource { #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] V4 { address: std::net::Ipv4Addr }, /// 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, - /// so it is carried rather than rediscovered. + /// arrived on. The interface is part of the identity of a *scoped* address, + /// 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"))] V6 { address: std::net::Ipv6Addr, diff --git a/crates/irtt-server/src/socket_io/ancillary.rs b/crates/irtt-server/src/socket_io/ancillary.rs index a75ffe8..ec8ae57 100644 --- a/crates/irtt-server/src/socket_io/ancillary.rs +++ b/crates/irtt-server/src/socket_io/ancillary.rs @@ -209,14 +209,13 @@ fn send_once( address, interface_index, } => { - // For a send, `in6_pktinfo` names the source address and the - // interface to leave by — the interface being what makes a scoped - // address unambiguous. + // For a send, `in6_pktinfo` names the source address and, where it + // is needed, the interface to leave by. let info = libc::in6_pktinfo { ipi6_addr: libc::in6_addr { 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 // message is: an IPv6 source with an `AF_INET` destination is a @@ -238,6 +237,28 @@ fn send_once( 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. /// /// A datagram sent to an IPv4-mapped destination from an `AF_INET6` socket goes @@ -316,3 +337,40 @@ impl ControlBuffer { &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}" + ); + } + } +}