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
2 changed files with 56 additions and 1 deletions
Showing only changes of commit e79ea94318 - Show all commits

No files matched your search

+24 -1
View File
2
@@ -213,7 +213,7 @@ impl ServerCore {
fn handle_close(&mut self, peer: SocketAddr, token: u64) {
let owns_session = self
.session(token)
.is_some_and(|session| session.peer() == peer);
.is_some_and(|session| same_endpoint(session.peer(), peer));
if owns_session {
self.sessions.remove(&token);
}
@@ -243,6 +243,29 @@ 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.
///
/// [`SocketAddrV6`]: std::net::SocketAddrV6
fn same_endpoint(session: SocketAddr, peer: SocketAddr) -> bool {
match (session, peer) {
(SocketAddr::V6(session), SocketAddr::V6(peer)) => {
session.ip() == peer.ip()
&& session.port() == peer.port()
&& session.scope_id() == peer.scope_id()
}
(session, peer) => session == peer,
}
}
/// Applies the open parameter rules the decoder cannot express.
///
/// Duration and Interval are accepted when absent, taking the wire default
+32
View File
@@ -6,6 +6,8 @@
//! authentication — is the same silence with the session left live, so these
//! tests assert the table as well as the reply.
use std::net::{SocketAddr, SocketAddrV6};
use irtt_proto::{encode_request, RequestToEncode};
use super::support::{
@@ -124,6 +126,36 @@ fn a_close_from_any_other_endpoint_leaves_the_session_live() {
}
}
#[test]
fn ipv6_flow_information_is_not_part_of_endpoint_identity() {
// Identity is family, address, port and scope. The flow label is routing
// metadata that may vary within one flow, so letting `SocketAddrV6`
// equality decide would strand a session its own client could never close.
let ip = "2001:db8::1".parse().unwrap();
let unlabeled = SocketAddr::V6(SocketAddrV6::new(ip, 2112, 0, 0));
let labeled = SocketAddr::V6(SocketAddrV6::new(ip, 2112, 0x000f_1234, 0));
assert_ne!(
unlabeled, labeled,
"the two must differ only in flow information"
);
for (bound, closing) in [(unlabeled, labeled), (labeled, unlabeled)] {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let token = open_session(&mut core, bound, None);
assert_eq!(
core.handle_datagram(closing, &close_request(token, None))
.unwrap(),
None
);
assert_eq!(
core.session_count(),
0,
"a close from {closing} must release the session opened from {bound}"
);
}
}
#[test]
fn an_unknown_or_zero_token_closes_nothing() {
// Zero is reserved for no-test replies and is never issued to a session, so