From 6c34fd3d2067f3862a5915b9651e90c0ff964a0d Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Wed, 12 Aug 2026 10:48:42 +0700 Subject: [PATCH] docs(server): record IPv6 zone identity as project policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoint identity documented the IPv6 scope identifier as an established component alongside family, address and port. The server specification does list the zone, but its remaining-unknowns section records that multi-zone behavior could not be tested on a single-host platform, so no evidence settles how a reference server compares scoped addresses. Presenting the choice as verified compatibility overstated what is known, which the crate's own guidance forbids. Say what it is instead: a deliberate policy taken under a recorded unknown. Two peers reachable at the same link-local address in different zones are genuinely different peers, so treating the zone as identity is the conservative reading — the failure it prevents is one peer closing another's session, and the failure it risks is a scoped client having to reopen. The excluded flow label is labelled the same way, and `irtt-server` guidance now carries the rule so the echo slice reuses this comparison rather than inventing a second one. Behavior is unchanged; this is documentation and a test comment. --- crates/irtt-server/AGENTS.md | 8 +++++++ crates/irtt-server/src/core.rs | 32 ++++++++++++++++++++------- crates/irtt-server/src/session.rs | 16 +++++++++----- crates/irtt-server/src/tests/close.rs | 7 ++++-- 4 files changed, 48 insertions(+), 15 deletions(-) diff --git a/crates/irtt-server/AGENTS.md b/crates/irtt-server/AGENTS.md index 4def913..3069944 100644 --- a/crates/irtt-server/AGENTS.md +++ b/crates/irtt-server/AGENTS.md @@ -25,6 +25,14 @@ material does not establish a semantic, state the ambiguity instead of guessing. - No statistics, CLI or presentation concerns. - `SocketAddr` belongs here: endpoint identity is session state. It does not belong in `irtt-proto`. +- Endpoint identity is compared field by field, not with `SocketAddr` equality. + Family, address and port are established components. The IPv6 flow label is + excluded because it identifies no endpoint; the IPv6 zone is included as a + deliberate project policy, because the specification records multi-zone + behavior as untested and two peers sharing a link-local address in different + zones are genuinely different peers. Reuse that one comparison rather than + writing a second identity rule, and keep both choices labelled as policy + rather than as verified compatibility. ## Core versus runtime diff --git a/crates/irtt-server/src/core.rs b/crates/irtt-server/src/core.rs index fa45071..7320c0e 100644 --- a/crates/irtt-server/src/core.rs +++ b/crates/irtt-server/src/core.rs @@ -245,14 +245,30 @@ impl ServerCore { /// Whether two source endpoints identify the same session endpoint. /// -/// Identity is the address family, the IP address, the UDP source port and — -/// for IPv6 — the scope identifier. That is the whole list, so this is not -/// simply `==`: [`SocketAddrV6`] equality also covers the flow label, which -/// identifies no endpoint. It is routing metadata that a sender or a forwarding -/// path may legitimately vary within one flow, and a receiver reports it only -/// when asked to, so admitting it into identity could strand a session that its -/// own client can then never close. It grants nothing in exchange: the token, -/// address, port and scope are what an off-path attacker would have to guess. +/// The address family, the IP address and the UDP source port are established +/// identity components: a request from a different port, address or family is +/// dropped and leaves its session live. +/// +/// The remaining two fields of [`SocketAddrV6`] are decided here rather than +/// observed, which is why this is not simply `==`. +/// +/// The flow label is excluded. It identifies no endpoint — it is routing +/// metadata a sender or forwarding path may legitimately vary within one flow, +/// and a receiver reports it only when asked to — so admitting it could strand +/// a session its own client can then never close. It grants nothing in +/// exchange: the token, address, port and scope are what an off-path attacker +/// would have to guess. +/// +/// The scope identifier is included, and that is **`irtt-rs` policy, not +/// verified compatibility behavior**. The server specification lists the IPv6 +/// zone as an identity component, but its remaining-unknowns section records +/// that multi-zone behavior could not be tested on a single-host platform, so +/// no evidence settles how a reference server compares scoped addresses. Two +/// peers reachable at the same link-local address in different zones are +/// genuinely different peers, so treating the zone as identity is the +/// conservative reading: the failure it prevents is one peer closing another's +/// session, while the failure it risks is a scoped client having to reopen. +/// Revisit if evidence ever settles the question. /// /// [`SocketAddrV6`]: std::net::SocketAddrV6 fn same_endpoint(session: SocketAddr, peer: SocketAddr) -> bool { diff --git a/crates/irtt-server/src/session.rs b/crates/irtt-server/src/session.rs index 328e60c..8411184 100644 --- a/crates/irtt-server/src/session.rs +++ b/crates/irtt-server/src/session.rs @@ -27,11 +27,17 @@ impl Session { /// The exact source endpoint the open request arrived from. /// - /// A session is bound to this endpoint, not merely to its address: address - /// family, address, UDP source port and — for IPv6 — the scope identifier - /// all form part of its identity. Close processing requires both a token - /// match and an exact endpoint match before removing a session, and echo - /// processing will require the same. + /// A session is bound to this endpoint, not merely to its address: the + /// address family, the address and the UDP source port all form part of its + /// identity, as — by `irtt-rs` policy rather than observed behavior — does + /// the IPv6 scope identifier. Close processing requires both a token match + /// and an endpoint match before removing a session, and echo processing + /// will require the same; `same_endpoint` in `core` is where the comparison + /// and its reasoning live. + /// + /// The address is stored exactly as received, including the flow label that + /// identity ignores, so a reply goes back to the endpoint the request + /// actually came from. pub(crate) fn peer(&self) -> SocketAddr { self.peer } diff --git a/crates/irtt-server/src/tests/close.rs b/crates/irtt-server/src/tests/close.rs index e6ca5a4..081d70b 100644 --- a/crates/irtt-server/src/tests/close.rs +++ b/crates/irtt-server/src/tests/close.rs @@ -91,8 +91,11 @@ fn an_echo_after_a_close_is_a_stale_token() { #[test] fn a_close_from_any_other_endpoint_leaves_the_session_live() { - // Endpoint identity is exact: family, address, UDP source port and IPv6 - // scope all count. Possessing the token is not authority to close. + // Possessing the token is not authority to close. The first four pairs + // differ in an established identity component; the last differs only in + // IPv6 zone, which is `irtt-rs` policy rather than observed behavior — see + // `same_endpoint`. It is pinned so the choice cannot drift silently, not + // because evidence settled it. for (bound, foreign) in [ ("198.51.100.7:41234", "198.51.100.7:41235"), ("198.51.100.7:41234", "198.51.100.9:41234"),