feat(server): handle client close #75

Merged
Lochnair merged 3 commits from feat/server-client-close into main 2026-08-12 03:55:07 +00:00
4 changed files with 48 additions and 15 deletions
Showing only changes of commit 6c34fd3d20 - Show all commits

No files matched your search

+8
View File
@@ -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
+24 -8
View File
2
@@ -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 {
+11 -5
View File
@@ -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
}
+5 -2
View File
@@ -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"),