feat(server): handle client close #75
No files matched your search
@@ -57,7 +57,7 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
|
|||||||
|
|
||||||
### Server architecture (`irtt-server`)
|
### Server architecture (`irtt-server`)
|
||||||
|
|
||||||
`irtt-server` is a first-class reusable server crate. It currently contains the deterministic OPEN/session core — packet admission, authentication policy, open negotiation, the bounded session table and open reply construction. ECHO processing, client/server close and session lifetime remain unimplemented, as does the Tokio runtime; see `crates/irtt-server/AGENTS.md`. The rest of this section describes the boundary the crate is built to, including the parts not yet written.
|
`irtt-server` is a first-class reusable server crate. It currently contains the deterministic OPEN/session core — packet admission, authentication policy, open negotiation, the bounded session table, open reply construction and client-initiated close. ECHO processing, server-initiated close and session lifetime remain unimplemented, as does the Tokio runtime; see `crates/irtt-server/AGENTS.md`. The rest of this section describes the boundary the crate is built to, including the parts not yet written.
|
||||||
|
|
||||||
- It shares wire semantics with the client through `irtt-proto` and follows the same clean-room boundary.
|
- It shares wire semantics with the client through `irtt-proto` and follows the same clean-room boundary.
|
||||||
- It must **not** depend on `irtt-client` merely to reuse protocol behavior.
|
- It must **not** depend on `irtt-client` merely to reuse protocol behavior.
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ material does not establish a semantic, state the ambiguity instead of guessing.
|
|||||||
- No statistics, CLI or presentation concerns.
|
- No statistics, CLI or presentation concerns.
|
||||||
- `SocketAddr` belongs here: endpoint identity is session state. It does not
|
- `SocketAddr` belongs here: endpoint identity is session state. It does not
|
||||||
belong in `irtt-proto`.
|
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
|
## Core versus runtime
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ use crate::{
|
|||||||
///
|
///
|
||||||
/// # Scope
|
/// # Scope
|
||||||
///
|
///
|
||||||
/// This slice implements open handling and session creation. Echo and close
|
/// This slice implements open handling, session creation and client-initiated
|
||||||
/// requests are structurally recognized and then deliberately ignored — no
|
/// close. Echo requests are structurally recognized and then deliberately
|
||||||
/// reply, no session mutation — until their own slices.
|
/// ignored — no reply, no session mutation — until their own slice.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ServerCore {
|
pub struct ServerCore {
|
||||||
config: ServerConfig,
|
config: ServerConfig,
|
||||||
@@ -81,11 +81,8 @@ impl ServerCore {
|
|||||||
///
|
///
|
||||||
/// This is a lookup by token alone and is therefore *not* an admission
|
/// This is a lookup by token alone and is therefore *not* an admission
|
||||||
/// check: a request is bound to a session only when the token matches and
|
/// check: a request is bound to a session only when the token matches and
|
||||||
/// it arrived from the session's exact endpoint.
|
/// it arrived from the session's exact endpoint. Callers pair it with an
|
||||||
//
|
/// endpoint comparison, as close handling does.
|
||||||
// Called only by tests until echo and close processing arrive, which is
|
|
||||||
// what a token lookup is for.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(crate) fn session(&self, token: u64) -> Option<&Session> {
|
pub(crate) fn session(&self, token: u64) -> Option<&Session> {
|
||||||
self.sessions.get(&token)
|
self.sessions.get(&token)
|
||||||
}
|
}
|
||||||
@@ -101,6 +98,10 @@ impl ServerCore {
|
|||||||
/// payload, would exceed the session bound, or is a request kind this slice
|
/// payload, would exceed the session bound, or is a request kind this slice
|
||||||
/// does not implement. None of that mutates any live session.
|
/// does not implement. None of that mutates any live session.
|
||||||
///
|
///
|
||||||
|
/// `Ok(None)` is not only a rejection, though. A close request that does
|
||||||
|
/// release its session is answered with nothing as well, because protocol
|
||||||
|
/// version 1 defines no acknowledgement for one.
|
||||||
|
///
|
||||||
/// Authentication is checked before open parameters are decoded. That
|
/// Authentication is checked before open parameters are decoded. That
|
||||||
/// ordering is deliberate: a packet that fails authentication is discarded
|
/// ordering is deliberate: a packet that fails authentication is discarded
|
||||||
/// without parsing untrusted parameter data, and no reply behavior may
|
/// without parsing untrusted parameter data, and no reply behavior may
|
||||||
@@ -133,11 +134,14 @@ impl ServerCore {
|
|||||||
|
|
||||||
match request.kind {
|
match request.kind {
|
||||||
DecodedRequestKind::Open { no_test, params } => self.handle_open(peer, no_test, params),
|
DecodedRequestKind::Open { no_test, params } => self.handle_open(peer, no_test, params),
|
||||||
// Not implemented in this slice. A close must not remove a session
|
DecodedRequestKind::Close { token } => {
|
||||||
// and an echo must not advance receive state until the slices that
|
self.handle_close(peer, token);
|
||||||
// implement those behaviors; emitting a placeholder reply now would
|
Ok(None)
|
||||||
// be worse than silence.
|
}
|
||||||
DecodedRequestKind::Close { .. } | DecodedRequestKind::Echo { .. } => Ok(None),
|
// Not implemented in this slice. An echo must not advance receive
|
||||||
|
// state until the slice that implements it; emitting a placeholder
|
||||||
|
// reply now would be worse than silence.
|
||||||
|
DecodedRequestKind::Echo { .. } => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +195,30 @@ impl ServerCore {
|
|||||||
Ok(Some(packet))
|
Ok(Some(packet))
|
||||||
|
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
/// Releases the session a close request names, if that request owns one.
|
||||||
|
///
|
||||||
|
/// A close is bound to a session only when the token matches *and* the
|
||||||
|
/// datagram arrived from that session's exact endpoint, so holding a token
|
||||||
|
/// is not by itself authority to tear the session down: a close from a
|
||||||
|
/// foreign endpoint leaves it live, and one close cannot take down a
|
||||||
|
/// sibling session that the same peer opened separately. A token that is
|
||||||
|
/// unknown, already closed, or zero — zero is reserved for no-test replies
|
||||||
|
/// and never issued — matches nothing and leaves the table untouched.
|
||||||
|
///
|
||||||
|
/// Nothing is sent either way, which is why this returns no reply to
|
||||||
|
/// encode. Protocol version 1 defines no acknowledgement for a client
|
||||||
|
/// close, so a delivered close is indistinguishable to the client from a
|
||||||
|
/// lost one, and the freed capacity is immediately reusable because the
|
||||||
|
/// session table is the only record of it.
|
||||||
|
fn handle_close(&mut self, peer: SocketAddr, token: u64) {
|
||||||
|
let owns_session = self
|
||||||
|
.session(token)
|
||||||
|
.is_some_and(|session| same_endpoint(session.peer(), peer));
|
||||||
|
if owns_session {
|
||||||
|
self.sessions.remove(&token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Encodes a reply this server constructed. Any failure here is internal.
|
/// Encodes a reply this server constructed. Any failure here is internal.
|
||||||
fn encode_reply(&self, reply: &OpenReply) -> Result<Vec<u8>, ServerError> {
|
fn encode_reply(&self, reply: &OpenReply) -> Result<Vec<u8>, ServerError> {
|
||||||
encode_open_reply(reply, self.config.hmac_key())
|
encode_open_reply(reply, self.config.hmac_key())
|
||||||
@@ -215,6 +243,45 @@ impl ServerCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether two source endpoints identify the same session endpoint.
|
||||||
|
///
|
||||||
|
/// 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 {
|
||||||
|
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.
|
/// Applies the open parameter rules the decoder cannot express.
|
||||||
///
|
///
|
||||||
/// Duration and Interval are accepted when absent, taking the wire default
|
/// Duration and Interval are accepted when absent, taking the wire default
|
||||||
|
|||||||
@@ -31,10 +31,10 @@
|
|||||||
//!
|
//!
|
||||||
//! # Current scope
|
//! # Current scope
|
||||||
//!
|
//!
|
||||||
//! Open handling and session creation only. Echo processing, client-initiated
|
//! Open handling, session creation and client-initiated close. Echo
|
||||||
//! close, session lifetime and expiry, and the Tokio runtime are separate
|
//! processing, server-initiated close, session lifetime and expiry, and the
|
||||||
//! slices; structurally valid echo and close requests are currently accepted by
|
//! Tokio runtime are separate slices; a structurally valid echo request is
|
||||||
//! the admission boundary and then ignored.
|
//! currently accepted by the admission boundary and then ignored.
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
|
|||||||
@@ -27,17 +27,17 @@ impl Session {
|
|||||||
|
|
||||||
/// The exact source endpoint the open request arrived from.
|
/// The exact source endpoint the open request arrived from.
|
||||||
///
|
///
|
||||||
/// A session is bound to this endpoint, not merely to its address: address
|
/// A session is bound to this endpoint, not merely to its address: the
|
||||||
/// family, address, UDP source port and — for IPv6 — the scope identifier
|
/// address family, the address and the UDP source port all form part of its
|
||||||
/// all form part of its identity. Echo and close processing, once
|
/// identity, as — by `irtt-rs` policy rather than observed behavior — does
|
||||||
/// implemented, must require both a token match and an exact endpoint match
|
/// the IPv6 scope identifier. Close processing requires both a token match
|
||||||
/// before touching a session.
|
/// and an endpoint match before removing a session, and echo processing
|
||||||
//
|
/// will require the same; `same_endpoint` in `core` is where the comparison
|
||||||
// Read only by tests until then. The endpoint has to be captured at open
|
/// and its reasoning live.
|
||||||
// time — it is not recoverable later — so this slice stores state whose
|
///
|
||||||
// production consumer is the next one, and the tests prove it is stored
|
/// The address is stored exactly as received, including the flow label that
|
||||||
// exactly.
|
/// identity ignores, so a reply goes back to the endpoint the request
|
||||||
#[allow(dead_code)]
|
/// actually came from.
|
||||||
pub(crate) fn peer(&self) -> SocketAddr {
|
pub(crate) fn peer(&self) -> SocketAddr {
|
||||||
self.peer
|
self.peer
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
//! Client-initiated close.
|
||||||
|
//!
|
||||||
|
//! A close request that matches a live token *and* arrived from that session's
|
||||||
|
//! exact endpoint releases the session and is answered with nothing. Every
|
||||||
|
//! other close — unknown, stale or zero token, foreign endpoint, failed
|
||||||
|
//! 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::{
|
||||||
|
client_params, close_request, close_request_with_trailing, core_for, core_with_tokens,
|
||||||
|
open_request, open_session, other_peer, peer, ScriptedTokens, KEY, OTHER_KEY,
|
||||||
|
};
|
||||||
|
use crate::ServerConfig;
|
||||||
|
|
||||||
|
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||||
|
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||||
|
const UNKNOWN_TOKEN: u64 = 0x2122_2324_2526_2728;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_valid_close_removes_the_session_and_answers_nothing() {
|
||||||
|
for hmac_key in [None, Some(KEY)] {
|
||||||
|
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||||
|
let mut core = core_for(hmac_key, tokens.clone());
|
||||||
|
let token = open_session(&mut core, peer(), hmac_key);
|
||||||
|
assert_eq!(core.session_count(), 1);
|
||||||
|
|
||||||
|
// Exactly `None`, not an empty or acknowledging packet: version 1 has
|
||||||
|
// no close reply, so the client learns nothing about the outcome.
|
||||||
|
assert_eq!(
|
||||||
|
core.handle_datagram(peer(), &close_request(token, hmac_key))
|
||||||
|
.unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(core.session_count(), 0);
|
||||||
|
assert_eq!(core.session(token), None);
|
||||||
|
assert_eq!(
|
||||||
|
tokens.remaining(),
|
||||||
|
0,
|
||||||
|
"a close must not draw from the token source"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_repeated_close_is_a_stale_token_and_changes_nothing() {
|
||||||
|
for hmac_key in [None, Some(KEY)] {
|
||||||
|
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||||
|
let token = open_session(&mut core, peer(), hmac_key);
|
||||||
|
let close = close_request(token, hmac_key);
|
||||||
|
|
||||||
|
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
|
||||||
|
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
|
||||||
|
|
||||||
|
assert_eq!(core.session_count(), 0, "nothing may be resurrected");
|
||||||
|
assert_eq!(core.session(token), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_echo_after_a_close_is_a_stale_token() {
|
||||||
|
// Echo processing does not exist yet; the point here is that the token is
|
||||||
|
// gone, so the request has no session to reach even once it does.
|
||||||
|
for hmac_key in [None, Some(KEY)] {
|
||||||
|
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||||
|
let token = open_session(&mut core, peer(), hmac_key);
|
||||||
|
let params = core.session(token).expect("session must exist").params();
|
||||||
|
let echo = encode_request(
|
||||||
|
RequestToEncode::Echo {
|
||||||
|
token,
|
||||||
|
sequence: 0,
|
||||||
|
params,
|
||||||
|
payload: &[],
|
||||||
|
},
|
||||||
|
hmac_key,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
core.handle_datagram(peer(), &close_request(token, hmac_key))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(core.handle_datagram(peer(), &echo).unwrap(), None);
|
||||||
|
assert_eq!(core.session_count(), 0);
|
||||||
|
assert_eq!(core.session(token), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_close_from_any_other_endpoint_leaves_the_session_live() {
|
||||||
|
// 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"),
|
||||||
|
("198.51.100.7:41234", "[::ffff:198.51.100.7]:41234"),
|
||||||
|
("[2001:db8::1]:2112", "[2001:db8::2]:2112"),
|
||||||
|
("[fe80::1%3]:2112", "[fe80::1%4]:2112"),
|
||||||
|
] {
|
||||||
|
let bound = bound.parse().unwrap();
|
||||||
|
let foreign = foreign.parse().unwrap();
|
||||||
|
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||||
|
let token = open_session(&mut core, bound, None);
|
||||||
|
let session = core.session(token).cloned().expect("session must exist");
|
||||||
|
let close = close_request(token, None);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
core.handle_datagram(foreign, &close).unwrap(),
|
||||||
|
None,
|
||||||
|
"a close from {foreign} must be silent"
|
||||||
|
);
|
||||||
|
assert_eq!(core.session_count(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
core.session(token),
|
||||||
|
Some(&session),
|
||||||
|
"a close from {foreign} must not remove the session bound to {bound}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The same request from the session's own endpoint does close it, so
|
||||||
|
// the request itself was never the reason for the silence.
|
||||||
|
assert_eq!(core.handle_datagram(bound, &close).unwrap(), None);
|
||||||
|
assert_eq!(core.session_count(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
// it can only ever miss the table.
|
||||||
|
for (name, token) in [("an unknown token", UNKNOWN_TOKEN), ("a zero token", 0)] {
|
||||||
|
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||||
|
let live = open_session(&mut core, peer(), None);
|
||||||
|
let session = core.session(live).cloned().expect("session must exist");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
core.handle_datagram(peer(), &close_request(token, None))
|
||||||
|
.unwrap(),
|
||||||
|
None,
|
||||||
|
"{name} must be answered with silence"
|
||||||
|
);
|
||||||
|
assert_eq!(core.session_count(), 1, "{name} must remove nothing");
|
||||||
|
assert_eq!(core.session(live), Some(&session));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_close_that_fails_authentication_leaves_the_session_live() {
|
||||||
|
// Authentication runs before the session is looked up, so a close that
|
||||||
|
// fails it is indistinguishable from one bearing an unknown token — and it
|
||||||
|
// must not be able to tear down an authenticated session.
|
||||||
|
let mut core = core_for(Some(KEY), ScriptedTokens::new([TOKEN_A]));
|
||||||
|
let token = open_session(&mut core, peer(), Some(KEY));
|
||||||
|
let session = core.session(token).cloned().expect("session must exist");
|
||||||
|
|
||||||
|
let mut corrupted = close_request(token, Some(KEY));
|
||||||
|
corrupted[5] ^= 0x80;
|
||||||
|
|
||||||
|
for (name, packet) in [
|
||||||
|
(
|
||||||
|
"a close with no MAC field at all",
|
||||||
|
close_request(token, None),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a close signed with a different key",
|
||||||
|
close_request(token, Some(OTHER_KEY)),
|
||||||
|
),
|
||||||
|
("a close with one bit flipped in the MAC", corrupted),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
core.handle_datagram(peer(), &packet).unwrap(),
|
||||||
|
None,
|
||||||
|
"{name} must be answered with silence"
|
||||||
|
);
|
||||||
|
assert_eq!(core.session_count(), 1, "{name} must not remove a session");
|
||||||
|
assert_eq!(core.session(token), Some(&session));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_authenticating_server_ignores_an_authenticated_close() {
|
||||||
|
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||||
|
let token = open_session(&mut core, peer(), None);
|
||||||
|
let session = core.session(token).cloned().expect("session must exist");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
core.handle_datagram(peer(), &close_request(token, Some(KEY)))
|
||||||
|
.unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(core.session_count(), 1);
|
||||||
|
assert_eq!(core.session(token), Some(&session));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trailing_bytes_after_the_token_do_not_prevent_the_close() {
|
||||||
|
for hmac_key in [None, Some(KEY)] {
|
||||||
|
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||||
|
let token = open_session(&mut core, peer(), hmac_key);
|
||||||
|
|
||||||
|
let close = close_request_with_trailing(token, &[0xde, 0xad, 0xbe, 0xef], hmac_key);
|
||||||
|
|
||||||
|
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
|
||||||
|
assert_eq!(core.session_count(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_close_removes_only_the_session_it_names() {
|
||||||
|
// Repeated opens from one endpoint are independent sessions, so a close
|
||||||
|
// must be resolved by token, never by endpoint.
|
||||||
|
let mut core = core_with_tokens(
|
||||||
|
ServerConfig::default(),
|
||||||
|
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
|
||||||
|
);
|
||||||
|
let first = open_session(&mut core, peer(), None);
|
||||||
|
let second = open_session(&mut core, peer(), None);
|
||||||
|
let survivor = core.session(second).cloned().expect("session must exist");
|
||||||
|
assert_eq!(core.session_count(), 2);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
core.handle_datagram(peer(), &close_request(first, None))
|
||||||
|
.unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(core.session_count(), 1, "only one session may be released");
|
||||||
|
assert_eq!(core.session(first), None);
|
||||||
|
assert_eq!(core.session(second), Some(&survivor));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
core.handle_datagram(peer(), &close_request(second, None))
|
||||||
|
.unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(core.session_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_close_frees_session_capacity_immediately() {
|
||||||
|
// The session table is the only record of how full the server is, so a
|
||||||
|
// removal is reclaimed at once with no separate counter to drift.
|
||||||
|
let tokens = ScriptedTokens::new([TOKEN_A, TOKEN_B]);
|
||||||
|
let mut core = core_with_tokens(ServerConfig::default().with_max_sessions(1), tokens.clone());
|
||||||
|
let request = open_request(&client_params(), None);
|
||||||
|
|
||||||
|
let first = open_session(&mut core, peer(), None);
|
||||||
|
assert_eq!(
|
||||||
|
core.handle_datagram(other_peer(), &request).unwrap(),
|
||||||
|
None,
|
||||||
|
"the bound is reached, so the second open is refused"
|
||||||
|
);
|
||||||
|
assert_eq!(core.session_count(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
tokens.remaining(),
|
||||||
|
1,
|
||||||
|
"a refused open must not commit a token"
|
||||||
|
);
|
||||||
|
|
||||||
|
core.handle_datagram(peer(), &close_request(first, None))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(core.session_count(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
tokens.remaining(),
|
||||||
|
1,
|
||||||
|
"a close must not draw from the token source"
|
||||||
|
);
|
||||||
|
|
||||||
|
let second = open_session(&mut core, other_peer(), None);
|
||||||
|
assert_eq!(second, TOKEN_B);
|
||||||
|
assert_ne!(second, 0);
|
||||||
|
assert_eq!(core.session_count(), 1);
|
||||||
|
assert_eq!(core.session(second).unwrap().peer(), other_peer());
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
//! Tokens come from a scripted source so session identity, collisions and
|
//! Tokens come from a scripted source so session identity, collisions and
|
||||||
//! allocation failure are all assertable.
|
//! allocation failure are all assertable.
|
||||||
|
|
||||||
|
mod close;
|
||||||
mod hmac;
|
mod hmac;
|
||||||
mod no_test;
|
mod no_test;
|
||||||
mod open;
|
mod open;
|
||||||
|
|||||||
@@ -72,6 +72,31 @@ pub(crate) fn core_with_tokens(config: ServerConfig, tokens: ScriptedTokens) ->
|
|||||||
ServerCore::with_token_source(config, Box::new(tokens))
|
ServerCore::with_token_source(config, Box::new(tokens))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An otherwise default core that authenticates iff `hmac_key` is set.
|
||||||
|
///
|
||||||
|
/// Most behavior has to hold identically with and without authentication, so
|
||||||
|
/// tests loop over both and build the core from the key.
|
||||||
|
pub(crate) fn core_for(hmac_key: Option<&[u8]>, tokens: ScriptedTokens) -> ServerCore {
|
||||||
|
let mut config = ServerConfig::default();
|
||||||
|
if let Some(key) = hmac_key {
|
||||||
|
config = config.with_hmac_key(key);
|
||||||
|
}
|
||||||
|
core_with_tokens(config, tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens one ordinary session from `endpoint` and returns its token.
|
||||||
|
pub(crate) fn open_session(
|
||||||
|
core: &mut ServerCore,
|
||||||
|
endpoint: SocketAddr,
|
||||||
|
hmac_key: Option<&[u8]>,
|
||||||
|
) -> u64 {
|
||||||
|
let packet = core
|
||||||
|
.handle_datagram(endpoint, &open_request(&client_params(), hmac_key))
|
||||||
|
.unwrap()
|
||||||
|
.expect("the session-creating open must be answered");
|
||||||
|
expect_normal_open_reply(&packet, hmac_key).token
|
||||||
|
}
|
||||||
|
|
||||||
/// Encodes a normal open request carrying `params`.
|
/// Encodes a normal open request carrying `params`.
|
||||||
pub(crate) fn open_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8> {
|
pub(crate) fn open_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8> {
|
||||||
encode_request(
|
encode_request(
|
||||||
@@ -96,6 +121,29 @@ pub(crate) fn no_test_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encodes a close request for `token`.
|
||||||
|
pub(crate) fn close_request(token: u64, hmac_key: Option<&[u8]>) -> Vec<u8> {
|
||||||
|
encode_request(RequestToEncode::Close { token }, hmac_key).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a close request carrying arbitrary bytes after the token.
|
||||||
|
///
|
||||||
|
/// The decoder tolerates them, and this is the shape that proves it. The MAC
|
||||||
|
/// covers the whole datagram, so it is recomputed over the final form rather
|
||||||
|
/// than left behind by the encoder's shorter packet.
|
||||||
|
pub(crate) fn close_request_with_trailing(
|
||||||
|
token: u64,
|
||||||
|
trailing: &[u8],
|
||||||
|
hmac_key: Option<&[u8]>,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut packet = close_request(token, hmac_key);
|
||||||
|
packet.extend_from_slice(trailing);
|
||||||
|
if let Some(key) = hmac_key {
|
||||||
|
irtt_proto::compute_hmac_in_place(key, &mut packet, hmac_field_offset()).unwrap();
|
||||||
|
}
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds an open request around a raw parameter payload.
|
/// Builds an open request around a raw parameter payload.
|
||||||
///
|
///
|
||||||
/// Used for payloads a compliant encoder cannot produce, such as a truncated
|
/// Used for payloads a compliant encoder cannot produce, such as a truncated
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
//! Requests this slice deliberately does not implement.
|
//! Requests this slice deliberately does not implement.
|
||||||
//!
|
//!
|
||||||
//! Echo and close are recognized by the admission boundary and then ignored.
|
//! Echo is recognized by the admission boundary and then ignored. These tests
|
||||||
//! These tests exist to keep the scope boundary honest: a half-implemented
|
//! exist to keep the scope boundary honest: receive state must not appear by
|
||||||
//! lifecycle must not appear by accident before the slices that own it.
|
//! accident before the slice that owns it.
|
||||||
|
|
||||||
use irtt_proto::{encode_request, RequestToEncode};
|
use irtt_proto::{encode_request, RequestToEncode};
|
||||||
|
|
||||||
use super::support::{client_params, core_with_tokens, open_request, peer, ScriptedTokens, KEY};
|
use super::support::{
|
||||||
use crate::ServerConfig;
|
client_params, core_for, open_request, open_session, peer, ScriptedTokens, KEY,
|
||||||
|
};
|
||||||
|
|
||||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||||
|
|
||||||
fn open_one_session(hmac_key: Option<&[u8]>) -> crate::ServerCore {
|
fn open_one_session(hmac_key: Option<&[u8]>) -> crate::ServerCore {
|
||||||
let mut config = ServerConfig::default();
|
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||||
if let Some(key) = hmac_key {
|
open_session(&mut core, peer(), hmac_key);
|
||||||
config = config.with_hmac_key(key);
|
|
||||||
}
|
|
||||||
let mut core = core_with_tokens(config, ScriptedTokens::new([TOKEN_A]));
|
|
||||||
core.handle_datagram(peer(), &open_request(&client_params(), hmac_key))
|
|
||||||
.unwrap()
|
|
||||||
.expect("the session-creating open must succeed");
|
|
||||||
core
|
core
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,20 +41,6 @@ fn a_valid_echo_is_neither_answered_nor_counted_yet() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_valid_close_does_not_remove_the_session_yet() {
|
|
||||||
for hmac_key in [None, Some(KEY)] {
|
|
||||||
let mut core = open_one_session(hmac_key);
|
|
||||||
let session = core.session(TOKEN_A).cloned().expect("session must exist");
|
|
||||||
|
|
||||||
let close = encode_request(RequestToEncode::Close { token: TOKEN_A }, hmac_key).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
|
|
||||||
assert_eq!(core.session_count(), 1);
|
|
||||||
assert_eq!(core.session(TOKEN_A), Some(&session));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn structurally_invalid_datagrams_are_discarded() {
|
fn structurally_invalid_datagrams_are_discarded() {
|
||||||
let mut core = open_one_session(None);
|
let mut core = open_one_session(None);
|
||||||
|
|||||||
Reference in new issue
Block a user
When an IPv6 open and close have the same address, port, scope ID, and family but their
SocketAddrV6::flowinfo()values differ, this equality check rejects the close because Rust'sSocketAddrV6equality includesflowinfo. Flow information is not one of the session-identity components defined by the local server specification, so the publicServerCorecan leave a valid session live solely because callers supplied different ancillary flow information; compare only the specified endpoint fields or normalize flowinfo before storing/matching.AGENTS.md reference: crates/irtt-server/AGENTS.md:L7-L16
Useful? React with 👍 / 👎.
Fixed in
e79ea94— endpoint identity now compares family, address, port and IPv6 scope explicitly instead of usingSocketAddrV6equality, so the flow label no longer counts; scope still does, and the address is still stored verbatim for replies. Not reachable through a default socket (a receiver reports the flow label only when asked, and there is no runtime slice yet), but it is reachable through the public core, and a stranded session has nothing to reclaim it while expiry is unimplemented. Covered byipv6_flow_information_is_not_part_of_endpoint_identity.🤖 Addressed by Claude Code