feat(server): carry reply traffic class
This commit is contained in:
12 files changed
+310
-57
No files matched your search
+107
-11
@@ -32,6 +32,11 @@ use crate::{
|
|||||||
/// -> None -> send nothing
|
/// -> None -> send nothing
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
|
/// A reply is an [`OutboundDatagram`]: the bytes to send plus the transport
|
||||||
|
/// policy that sending them requires. The core is where session policy lives, so
|
||||||
|
/// it hands that policy out with the packet rather than leaving the runtime to
|
||||||
|
/// rediscover it from the session table or from the packet it is about to send.
|
||||||
|
///
|
||||||
/// It is public as a low-level engine for a pre-1.0 crate, not as a promise of
|
/// It is public as a low-level engine for a pre-1.0 crate, not as a promise of
|
||||||
/// runtime independence. The server is intentionally Tokio-native; this is not
|
/// runtime independence. The server is intentionally Tokio-native; this is not
|
||||||
/// a transport abstraction, and there is no blocking or alternate-runtime
|
/// a transport abstraction, and there is no blocking or alternate-runtime
|
||||||
@@ -41,11 +46,12 @@ use crate::{
|
|||||||
///
|
///
|
||||||
/// The core implements open handling, session creation and negotiation,
|
/// The core implements open handling, session creation and negotiation,
|
||||||
/// normal echo processing, per-session rate limiting, idle expiry,
|
/// normal echo processing, per-session rate limiting, idle expiry,
|
||||||
/// maximum-duration close and client-initiated close. DSCP application and the
|
/// maximum-duration close and client-initiated close. It decides the traffic
|
||||||
/// full server fill policy are separate slices, and echo payloads are
|
/// class each reply is to be sent with; actually applying it to a socket is the
|
||||||
/// zero-filled. Incoming authenticated requests enforce logical expiry exactly;
|
/// runtime's job. The full server fill policy is a separate slice, and echo
|
||||||
/// the runtime also calls a private maintenance hook to reclaim idle sessions
|
/// payloads are zero-filled. Incoming authenticated requests enforce logical
|
||||||
/// when no traffic arrives.
|
/// expiry exactly; the runtime also calls a private maintenance hook to reclaim
|
||||||
|
/// idle sessions when no traffic arrives.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ServerCore {
|
pub struct ServerCore {
|
||||||
config: ServerConfig,
|
config: ServerConfig,
|
||||||
@@ -111,6 +117,14 @@ impl ServerCore {
|
|||||||
|
|
||||||
/// Handles one received datagram, returning the reply to send, if any.
|
/// Handles one received datagram, returning the reply to send, if any.
|
||||||
///
|
///
|
||||||
|
/// A reply is an [`OutboundDatagram`]: the encoded packet and the raw
|
||||||
|
/// traffic class it must be sent with. Open and no-test replies are always
|
||||||
|
/// unmarked, and an echo reply — including one carrying the
|
||||||
|
/// maximum-duration close — takes its session's negotiated class. The
|
||||||
|
/// runtime applies that class to the socket before sending; nothing here
|
||||||
|
/// touches a socket, and a class this core hands out is a request, not a
|
||||||
|
/// promise that the host can honor it.
|
||||||
|
///
|
||||||
/// `Ok(None)` means "answer nothing". The protocol defines no error reply,
|
/// `Ok(None)` means "answer nothing". The protocol defines no error reply,
|
||||||
/// no reset and no NACK, so every rejection is a silent discard and a
|
/// no reset and no NACK, so every rejection is a silent discard and a
|
||||||
/// client distinguishes "rejected" from "lost" only by timing out. A
|
/// client distinguishes "rejected" from "lost" only by timing out. A
|
||||||
@@ -149,7 +163,7 @@ impl ServerCore {
|
|||||||
&mut self,
|
&mut self,
|
||||||
peer: SocketAddr,
|
peer: SocketAddr,
|
||||||
packet: &[u8],
|
packet: &[u8],
|
||||||
) -> Result<Option<Vec<u8>>, ServerError> {
|
) -> Result<Option<OutboundDatagram>, ServerError> {
|
||||||
let Ok(request) = decode_request(packet) else {
|
let Ok(request) = decode_request(packet) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -295,7 +309,7 @@ impl ServerCore {
|
|||||||
&mut self,
|
&mut self,
|
||||||
peer: SocketAddr,
|
peer: SocketAddr,
|
||||||
request: EchoRequest,
|
request: EchoRequest,
|
||||||
) -> Result<Option<Vec<u8>>, ServerError> {
|
) -> Result<Option<OutboundDatagram>, ServerError> {
|
||||||
// Resource policy, applied to the length actually received. This is
|
// Resource policy, applied to the length actually received. This is
|
||||||
// `irtt-rs` policy rather than upstream-mechanism emulation: there is
|
// `irtt-rs` policy rather than upstream-mechanism emulation: there is
|
||||||
// no interface-MTU effective length here, just the datagram the caller
|
// no interface-MTU effective length here, just the datagram the caller
|
||||||
@@ -341,6 +355,10 @@ impl ServerCore {
|
|||||||
// it.
|
// it.
|
||||||
let sent_at = self.clock.sample();
|
let sent_at = self.clock.sample();
|
||||||
let params = session.params();
|
let params = session.params();
|
||||||
|
// Read before the session can be released below: a maximum-duration
|
||||||
|
// close is an ordinary echo reply and carries the session's marking like
|
||||||
|
// any other.
|
||||||
|
let traffic_class = transport_traffic_class(params);
|
||||||
let reply = EchoReply {
|
let reply = EchoReply {
|
||||||
// Open must be clear or upstream clients abort. Close is set only
|
// Open must be clear or upstream clients abort. Close is set only
|
||||||
// for the maximum-duration limit, which is the sole trigger this
|
// for the maximum-duration limit, which is the sole trigger this
|
||||||
@@ -376,7 +394,7 @@ impl ServerCore {
|
|||||||
session.commit_receive_state(next);
|
session.commit_receive_state(next);
|
||||||
session.commit_lifetime_state(next_lifetime);
|
session.commit_lifetime_state(next_lifetime);
|
||||||
}
|
}
|
||||||
Ok(Some(packet))
|
Ok(Some(OutboundDatagram::new(packet, traffic_class)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Negotiates an open request and, unless it is a no-test one, creates the
|
/// Negotiates an open request and, unless it is a no-test one, creates the
|
||||||
@@ -393,7 +411,7 @@ impl ServerCore {
|
|||||||
no_test: bool,
|
no_test: bool,
|
||||||
encoded_params: &[u8],
|
encoded_params: &[u8],
|
||||||
now_ns: i64,
|
now_ns: i64,
|
||||||
) -> Result<Option<Vec<u8>>, ServerError> {
|
) -> Result<Option<OutboundDatagram>, ServerError> {
|
||||||
// The parameter decoder already rejects a truncated tag or value, a
|
// The parameter decoder already rejects a truncated tag or value, a
|
||||||
// varint overflow, an out-of-range stats/stamp-at/clock enum, and a
|
// varint overflow, an out-of-range stats/stamp-at/clock enum, and a
|
||||||
// server-fill value that is oversized, short-buffered or not UTF-8.
|
// server-fill value that is oversized, short-buffered or not UTF-8.
|
||||||
@@ -419,7 +437,9 @@ impl ServerCore {
|
|||||||
token: 0,
|
token: 0,
|
||||||
params,
|
params,
|
||||||
};
|
};
|
||||||
return self.encode_reply(&reply).map(Some);
|
return self
|
||||||
|
.encode_reply(&reply)
|
||||||
|
.map(|packet| Some(OutboundDatagram::unmarked(packet)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.sessions.len() >= self.config.max_sessions() {
|
if self.sessions.len() >= self.config.max_sessions() {
|
||||||
@@ -442,7 +462,7 @@ impl ServerCore {
|
|||||||
token,
|
token,
|
||||||
Session::new(peer, reply.params, &self.config, now_ns),
|
Session::new(peer, reply.params, &self.config, now_ns),
|
||||||
);
|
);
|
||||||
Ok(Some(packet))
|
Ok(Some(OutboundDatagram::unmarked(packet)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Releases the session a close request names, if that request owns one.
|
/// Releases the session a close request names, if that request owns one.
|
||||||
@@ -493,6 +513,82 @@ impl ServerCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One reply the core prepared, together with the transport policy it must be
|
||||||
|
/// sent under.
|
||||||
|
///
|
||||||
|
/// The bytes alone are not a complete instruction. A session negotiates a
|
||||||
|
/// traffic class, and only the core knows which session a reply belongs to, so
|
||||||
|
/// the class travels out with the packet instead of the runtime decoding the
|
||||||
|
/// outgoing datagram again or keeping a second copy of session state.
|
||||||
|
///
|
||||||
|
/// [`traffic_class`](Self::traffic_class) is the **raw 8-bit IPv4 TOS / IPv6
|
||||||
|
/// Traffic Class byte**, not a six-bit codepoint: the codepoint is its upper six
|
||||||
|
/// bits and the low two are ECN. Zero is a value like any other and means
|
||||||
|
/// unmarked — it is deliberately not `Option<u8>`, because a runtime sharing one
|
||||||
|
/// socket between sessions has to apply zero as explicitly as it applies
|
||||||
|
/// anything else, or an unmarked reply inherits the previous one's marking.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct OutboundDatagram {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
traffic_class: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutboundDatagram {
|
||||||
|
/// A reply to send with an explicit raw traffic-class byte.
|
||||||
|
fn new(bytes: Vec<u8>, traffic_class: u8) -> Self {
|
||||||
|
Self {
|
||||||
|
bytes,
|
||||||
|
traffic_class,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reply to send unmarked.
|
||||||
|
fn unmarked(bytes: Vec<u8>) -> Self {
|
||||||
|
Self::new(bytes, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The encoded reply.
|
||||||
|
#[must_use]
|
||||||
|
pub fn bytes(&self) -> &[u8] {
|
||||||
|
&self.bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The raw IPv4 TOS / IPv6 Traffic Class byte this reply is to be sent
|
||||||
|
/// with. Zero means unmarked.
|
||||||
|
#[must_use]
|
||||||
|
pub fn traffic_class(&self) -> u8 {
|
||||||
|
self.traffic_class
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes the datagram, yielding its encoded reply.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_bytes(self) -> Vec<u8> {
|
||||||
|
self.bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<[u8]> for OutboundDatagram {
|
||||||
|
fn as_ref(&self) -> &[u8] {
|
||||||
|
self.bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The raw traffic-class byte a session's negotiated DSCP parameter asks for.
|
||||||
|
///
|
||||||
|
/// The wire parameter is an `i64` and this server deliberately accepts values
|
||||||
|
/// outside `0..=255` during negotiation, returning and storing them unchanged.
|
||||||
|
/// A value that is not a byte cannot be a TOS / Traffic Class byte, so the
|
||||||
|
/// transport treats it as unmarked. That is **`irtt-rs` policy, not observed
|
||||||
|
/// behavior**: the clean evidence records the reference host's handling of
|
||||||
|
/// negative and oversized values as platform-specific and explicitly not a
|
||||||
|
/// compatibility requirement, so there is nothing to reproduce. Zero is chosen
|
||||||
|
/// over `as`-style truncation or two's-complement wrapping because neither
|
||||||
|
/// −1 → 255 nor 256 → 0 is a marking the client asked for, and over refusing the
|
||||||
|
/// session because a malformed-but-accepted open should stay usable.
|
||||||
|
fn transport_traffic_class(params: &Params) -> u8 {
|
||||||
|
u8::try_from(params.dscp).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
/// A structurally decoded request kind, with an echo's arrival instant already
|
/// A structurally decoded request kind, with an echo's arrival instant already
|
||||||
/// sampled.
|
/// sampled.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
//! receive state, rate and lifetime policy, resource decisions and reply
|
//! receive state, rate and lifetime policy, resource decisions and reply
|
||||||
//! construction. It performs no I/O; the clock its timestamps and deadlines
|
//! construction. It performs no I/O; the clock its timestamps and deadlines
|
||||||
//! come from is a private injected seam, not a runtime abstraction, so the
|
//! come from is a private injected seam, not a runtime abstraction, so the
|
||||||
//! engine stays testable. [`Server`] owns one Tokio UDP listener and one core,
|
//! engine stays testable. Each reply it produces is an [`OutboundDatagram`]:
|
||||||
//! and provides sequential receive, reply, scheduled expiry maintenance and
|
//! the packet and the raw traffic class it must be sent with. [`Server`] owns
|
||||||
//! caller-controlled shutdown. The crate is intentionally Tokio-native; there
|
//! one Tokio UDP listener and one core, and provides sequential receive, reply,
|
||||||
//! is no blocking or alternate-runtime counterpart and no transport
|
//! scheduled expiry maintenance and caller-controlled shutdown. The crate is
|
||||||
//! abstraction.
|
//! intentionally Tokio-native; there is no blocking or alternate-runtime
|
||||||
|
//! counterpart and no transport abstraction.
|
||||||
//!
|
//!
|
||||||
//! # Rejection is silence
|
//! # Rejection is silence
|
||||||
//!
|
//!
|
||||||
@@ -63,6 +64,6 @@ pub use config::{
|
|||||||
ServerConfig, DEFAULT_BURST_ALLOWANCE, DEFAULT_IDLE_TIMEOUT, DEFAULT_MAX_PACKET_LENGTH,
|
ServerConfig, DEFAULT_BURST_ALLOWANCE, DEFAULT_IDLE_TIMEOUT, DEFAULT_MAX_PACKET_LENGTH,
|
||||||
DEFAULT_MAX_SESSIONS, DEFAULT_MIN_SEND_INTERVAL,
|
DEFAULT_MAX_SESSIONS, DEFAULT_MIN_SEND_INTERVAL,
|
||||||
};
|
};
|
||||||
pub use core::ServerCore;
|
pub use core::{OutboundDatagram, ServerCore};
|
||||||
pub use error::ServerError;
|
pub use error::ServerError;
|
||||||
pub use runtime::{Server, ServerRuntimeError};
|
pub use runtime::{Server, ServerRuntimeError};
|
||||||
@@ -90,7 +90,7 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(reply) = self.core.handle_datagram(peer, &self.recv_buffer[..len])? {
|
if let Some(reply) = self.core.handle_datagram(peer, &self.recv_buffer[..len])? {
|
||||||
let send = self.socket.send_to(&reply, peer);
|
let send = self.socket.send_to(reply.bytes(), peer);
|
||||||
tokio::pin!(send);
|
tokio::pin!(send);
|
||||||
let sent = loop {
|
let sent = loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -99,7 +99,7 @@ impl Server {
|
|||||||
sent = &mut send => break sent,
|
sent = &mut send => break sent,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !matches!(sent, Ok(len) if len == reply.len()) {
|
if !matches!(sent, Ok(len) if len == reply.bytes().len()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,8 +93,15 @@ fn a_valid_echo_is_answered_from_the_negotiated_params() {
|
|||||||
"no timestamps were negotiated, so none are emitted"
|
"no timestamps were negotiated, so none are emitted"
|
||||||
);
|
);
|
||||||
assert!(reply.payload.is_empty());
|
assert!(reply.payload.is_empty());
|
||||||
assert_eq!(packet.len(), echo_packet_len(false, &negotiated).unwrap());
|
assert_eq!(
|
||||||
assert_eq!(packet.len(), 16, "magic, flags, token and sequence only");
|
packet.bytes().len(),
|
||||||
|
echo_packet_len(false, &negotiated).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
packet.bytes().len(),
|
||||||
|
16,
|
||||||
|
"magic, flags, token and sequence only"
|
||||||
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
core.session_count(),
|
core.session_count(),
|
||||||
@@ -149,7 +156,7 @@ fn a_reply_is_sized_by_the_session_not_by_the_request() {
|
|||||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
packet.len(),
|
packet.bytes().len(),
|
||||||
64,
|
64,
|
||||||
"{name}: the reply is the negotiated length"
|
"{name}: the reply is the negotiated length"
|
||||||
);
|
);
|
||||||
@@ -418,7 +425,7 @@ fn an_authenticated_session_answers_only_authenticated_echoes() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("a correctly authenticated echo must be answered");
|
.expect("a correctly authenticated echo must be answered");
|
||||||
assert!(
|
assert!(
|
||||||
verify_packet_hmac(KEY, &packet).is_ok(),
|
verify_packet_hmac(KEY, packet.bytes()).is_ok(),
|
||||||
"the reply must carry a MAC that verifies with the server's key"
|
"the reply must carry a MAC that verifies with the server's key"
|
||||||
);
|
);
|
||||||
// `expect_echo_reply` pins the HMAC flag and verifies the MAC again while
|
// `expect_echo_reply` pins the HMAC flag and verifies the MAC again while
|
||||||
@@ -596,7 +603,7 @@ fn only_the_negotiated_timestamps_are_emitted() {
|
|||||||
// failure as much as a selected one carrying the wrong instant.
|
// failure as much as a selected one carrying the wrong instant.
|
||||||
assert_eq!(reply.timestamps, expected, "{stamp_at:?} on {clock:?}");
|
assert_eq!(reply.timestamps, expected, "{stamp_at:?} on {clock:?}");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
packet.len(),
|
packet.bytes().len(),
|
||||||
echo_packet_len(false, &negotiated).unwrap(),
|
echo_packet_len(false, &negotiated).unwrap(),
|
||||||
"{stamp_at:?} on {clock:?} must be the negotiated layout's length"
|
"{stamp_at:?} on {clock:?} must be the negotiated layout's length"
|
||||||
);
|
);
|
||||||
@@ -704,12 +711,12 @@ fn a_single_clock_midpoint_emits_exactly_one_field() {
|
|||||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
packet.len(),
|
packet.bytes().len(),
|
||||||
echo_packet_len(false, &negotiated).unwrap(),
|
echo_packet_len(false, &negotiated).unwrap(),
|
||||||
"{clock:?}: the negotiated length, not the compatibility one"
|
"{clock:?}: the negotiated length, not the compatibility one"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
packet.len(),
|
packet.bytes().len(),
|
||||||
24,
|
24,
|
||||||
"{clock:?}: one 8-byte midpoint field, not the upstream two"
|
"{clock:?}: one 8-byte midpoint field, not the upstream two"
|
||||||
);
|
);
|
||||||
@@ -734,7 +741,7 @@ fn a_reply_payload_is_zero_filled_and_never_reflects_the_request() {
|
|||||||
.expect("an admissible echo must be answered");
|
.expect("an admissible echo must be answered");
|
||||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||||
|
|
||||||
assert_eq!(packet.len(), 64);
|
assert_eq!(packet.bytes().len(), 64);
|
||||||
assert_eq!(reply.payload.len(), 48);
|
assert_eq!(reply.payload.len(), 48);
|
||||||
assert!(
|
assert!(
|
||||||
reply.payload.iter().all(|byte| *byte == 0),
|
reply.payload.iter().all(|byte| *byte == 0),
|
||||||
|
|||||||
@@ -94,8 +94,12 @@ fn an_authenticating_server_answers_a_valid_open_with_an_authenticated_reply() {
|
|||||||
|
|
||||||
let reply = expect_normal_open_reply(&packet, Some(KEY));
|
let reply = expect_normal_open_reply(&packet, Some(KEY));
|
||||||
assert_eq!(reply.token, TOKEN_A);
|
assert_eq!(reply.token, TOKEN_A);
|
||||||
assert_ne!(packet[3] & FLAG_HMAC, 0, "the reply must set FLAG_HMAC");
|
assert_ne!(
|
||||||
verify_packet_hmac(KEY, &packet).expect("the reply must carry a valid MAC");
|
packet.bytes()[3] & FLAG_HMAC,
|
||||||
|
0,
|
||||||
|
"the reply must set FLAG_HMAC"
|
||||||
|
);
|
||||||
|
verify_packet_hmac(KEY, packet.bytes()).expect("the reply must carry a valid MAC");
|
||||||
assert_eq!(core.session_count(), 1);
|
assert_eq!(core.session_count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +113,7 @@ fn a_non_authenticating_server_answers_an_ordinary_open() {
|
|||||||
.expect("an unauthenticated open must be answered");
|
.expect("an unauthenticated open must be answered");
|
||||||
|
|
||||||
expect_normal_open_reply(&packet, None);
|
expect_normal_open_reply(&packet, None);
|
||||||
assert_eq!(packet[3] & FLAG_HMAC, 0);
|
assert_eq!(packet.bytes()[3] & FLAG_HMAC, 0);
|
||||||
assert_eq!(core.session_count(), 1);
|
assert_eq!(core.session_count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use super::support::{
|
|||||||
expect_closing_echo_reply, expect_echo_reply, expect_no_test_reply, manual_core,
|
expect_closing_echo_reply, expect_echo_reply, expect_no_test_reply, manual_core,
|
||||||
no_test_request, open_request, other_peer, peer, ManualClock, ScriptedTokens, KEY,
|
no_test_request, open_request, other_peer, peer, ManualClock, ScriptedTokens, KEY,
|
||||||
};
|
};
|
||||||
use crate::ServerConfig;
|
use crate::{OutboundDatagram, ServerConfig};
|
||||||
|
|
||||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||||
@@ -50,9 +50,9 @@ fn idle_after(idle: Duration) -> ServerConfig {
|
|||||||
.with_min_send_interval(Duration::ZERO)
|
.with_min_send_interval(Duration::ZERO)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn count_of(packet: Option<Vec<u8>>, params: &Params) -> Option<u32> {
|
fn count_of(reply: Option<OutboundDatagram>, params: &Params) -> Option<u32> {
|
||||||
let packet = packet?;
|
let reply = reply?;
|
||||||
expect_echo_reply(&packet, params, None).recv_count
|
expect_echo_reply(reply, params, None).recv_count
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -451,10 +451,15 @@ fn the_first_echo_past_the_maximum_duration_carries_close_and_ends_the_session()
|
|||||||
);
|
);
|
||||||
assert!(reply.payload.is_empty());
|
assert!(reply.payload.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
packet.len(),
|
packet.bytes().len(),
|
||||||
irtt_proto::echo_packet_len(false, &negotiated).unwrap(),
|
irtt_proto::echo_packet_len(false, &negotiated).unwrap(),
|
||||||
"the negotiated layout, unchanged by the flag"
|
"the negotiated layout, unchanged by the flag"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
i64::from(packet.traffic_class()),
|
||||||
|
negotiated.dscp,
|
||||||
|
"a server close is an echo reply and keeps the session's marking"
|
||||||
|
);
|
||||||
|
|
||||||
// The token is unusable from that reply onward.
|
// The token is unusable from that reply onward.
|
||||||
assert_eq!(core.session_count(), 0);
|
assert_eq!(core.session_count(), 0);
|
||||||
@@ -595,6 +600,7 @@ fn an_authenticated_session_closes_through_the_ordinary_reply_path() {
|
|||||||
// together.
|
// together.
|
||||||
let reply = expect_closing_echo_reply(&packet, &negotiated, Some(KEY));
|
let reply = expect_closing_echo_reply(&packet, &negotiated, Some(KEY));
|
||||||
assert_eq!(reply.recv_count, Some(2));
|
assert_eq!(reply.recv_count, Some(2));
|
||||||
irtt_proto::verify_packet_hmac(KEY, &packet).expect("the close-flagged reply is authenticated");
|
irtt_proto::verify_packet_hmac(KEY, packet.bytes())
|
||||||
|
.expect("the close-flagged reply is authenticated");
|
||||||
assert_eq!(core.session_count(), 0);
|
assert_eq!(core.session_count(), 0);
|
||||||
}
|
}
|
||||||
@@ -24,3 +24,4 @@ mod rate;
|
|||||||
mod sessions;
|
mod sessions;
|
||||||
mod support;
|
mod support;
|
||||||
mod tokens;
|
mod tokens;
|
||||||
|
mod traffic_class;
|
||||||
@@ -194,7 +194,7 @@ fn an_authenticated_no_test_reply_is_authenticated_and_still_creates_nothing() {
|
|||||||
.expect("an authenticated no-test open must be answered");
|
.expect("an authenticated no-test open must be answered");
|
||||||
|
|
||||||
expect_no_test_reply(&packet, Some(KEY));
|
expect_no_test_reply(&packet, Some(KEY));
|
||||||
verify_packet_hmac(KEY, &packet).expect("the no-test reply must carry a valid MAC");
|
verify_packet_hmac(KEY, packet.bytes()).expect("the no-test reply must carry a valid MAC");
|
||||||
assert_eq!(core.session_count(), 0);
|
assert_eq!(core.session_count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ fn an_empty_parameter_payload_is_answered_with_only_a_version() {
|
|||||||
// header (4) + token (8) + ProtocolVersion=1 (2). Every other parameter
|
// header (4) + token (8) + ProtocolVersion=1 (2). Every other parameter
|
||||||
// negotiates to its wire-default zero and is omitted entirely, which is
|
// negotiates to its wire-default zero and is omitted entirely, which is
|
||||||
// only reachable if absent parameters really did decode as zero.
|
// only reachable if absent parameters really did decode as zero.
|
||||||
assert_eq!(packet.len(), 14);
|
assert_eq!(packet.bytes().len(), 14);
|
||||||
|
|
||||||
let reply = expect_normal_open_reply(&packet, None);
|
let reply = expect_normal_open_reply(&packet, None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -139,7 +139,7 @@ fn unknown_parameter_tags_are_ignored_and_not_reflected_in_the_reply() {
|
|||||||
);
|
);
|
||||||
// The reply is the same 14 bytes an empty payload produces: no unknown tag
|
// The reply is the same 14 bytes an empty payload produces: no unknown tag
|
||||||
// was copied through.
|
// was copied through.
|
||||||
assert_eq!(packet.len(), 14);
|
assert_eq!(packet.bytes().len(), 14);
|
||||||
assert_eq!(core.session_count(), 1);
|
assert_eq!(core.session_count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,9 +366,9 @@ fn authentication_counts_toward_the_executable_echo_size() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn negative_and_out_of_range_length_and_dscp_survive_negotiation() {
|
fn negative_and_out_of_range_length_and_dscp_survive_negotiation() {
|
||||||
// These lengths are all at or below the default maximum, and DSCP is
|
// These lengths are all at or below the default maximum, and DSCP is
|
||||||
// accepted as decoded in this slice, including values a socket could never
|
// accepted as decoded, including values a socket could never carry:
|
||||||
// carry. Restricting a DSCP the server cannot apply is a later policy
|
// negotiation restricts neither, and a value that is not a byte is simply
|
||||||
// decision, not an admission rule.
|
// transported unmarked (see `traffic_class`).
|
||||||
//
|
//
|
||||||
// A negative length in particular stays negative in both the reply and the
|
// A negative length in particular stays negative in both the reply and the
|
||||||
// stored session; the packet-length policy reduces oversized *positive*
|
// stored session; the packet-length policy reduces oversized *positive*
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use super::support::{
|
|||||||
core_with_sources, echo_at, echo_params, echo_request, expect_echo_reply, manual_core,
|
core_with_sources, echo_at, echo_params, echo_request, expect_echo_reply, manual_core,
|
||||||
open_negotiated, other_peer, peer, ManualClock, ScriptedTokens,
|
open_negotiated, other_peer, peer, ManualClock, ScriptedTokens,
|
||||||
};
|
};
|
||||||
use crate::ServerConfig;
|
use crate::{OutboundDatagram, ServerConfig};
|
||||||
|
|
||||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||||
@@ -41,9 +41,9 @@ fn params_at(interval_ns: i64) -> Params {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The received count a reply reported, or `None` if there was no reply.
|
/// The received count a reply reported, or `None` if there was no reply.
|
||||||
fn count_of(packet: Option<Vec<u8>>, params: &Params) -> Option<u32> {
|
fn count_of(reply: Option<OutboundDatagram>, params: &Params) -> Option<u32> {
|
||||||
let packet = packet?;
|
let reply = reply?;
|
||||||
expect_echo_reply(&packet, params, None).recv_count
|
expect_echo_reply(reply, params, None).recv_count
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use irtt_proto::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
clock::{ClockSample, ClockSource},
|
clock::{ClockSample, ClockSource},
|
||||||
core::ServerCore,
|
core::{OutboundDatagram, ServerCore},
|
||||||
error::ServerError,
|
error::ServerError,
|
||||||
token::TokenSource,
|
token::TokenSource,
|
||||||
ServerConfig,
|
ServerConfig,
|
||||||
@@ -231,7 +231,7 @@ pub(crate) fn echo_at(
|
|||||||
sequence: u32,
|
sequence: u32,
|
||||||
params: &Params,
|
params: &Params,
|
||||||
hmac_key: Option<&[u8]>,
|
hmac_key: Option<&[u8]>,
|
||||||
) -> Option<Vec<u8>> {
|
) -> Option<OutboundDatagram> {
|
||||||
clock.set(at_ns);
|
clock.set(at_ns);
|
||||||
core.handle_datagram(
|
core.handle_datagram(
|
||||||
peer(),
|
peer(),
|
||||||
@@ -314,11 +314,12 @@ pub(crate) fn echo_request(
|
|||||||
/// Decodes an echo reply the server produced and checks the flags every reply
|
/// Decodes an echo reply the server produced and checks the flags every reply
|
||||||
/// must carry. Decoding verifies the MAC against `hmac_key` on the way.
|
/// must carry. Decoding verifies the MAC against `hmac_key` on the way.
|
||||||
pub(crate) fn expect_echo_reply(
|
pub(crate) fn expect_echo_reply(
|
||||||
packet: &[u8],
|
packet: impl AsRef<[u8]>,
|
||||||
params: &Params,
|
params: &Params,
|
||||||
hmac_key: Option<&[u8]>,
|
hmac_key: Option<&[u8]>,
|
||||||
) -> EchoReply {
|
) -> EchoReply {
|
||||||
let reply = decode_echo_reply(packet, params, hmac_key).expect("server echo reply must decode");
|
let reply = decode_echo_reply(packet.as_ref(), params, hmac_key)
|
||||||
|
.expect("server echo reply must decode");
|
||||||
// Exact equality, so Open and Close are pinned clear as well: Open makes an
|
// Exact equality, so Open and Close are pinned clear as well: Open makes an
|
||||||
// upstream client abort, and Close belongs to the server-initiated close
|
// upstream client abort, and Close belongs to the server-initiated close
|
||||||
// slice.
|
// slice.
|
||||||
@@ -334,12 +335,12 @@ pub(crate) fn expect_echo_reply(
|
|||||||
/// by the same path as any other, which is the point: the close is not a
|
/// by the same path as any other, which is the point: the close is not a
|
||||||
/// separate packet kind.
|
/// separate packet kind.
|
||||||
pub(crate) fn expect_closing_echo_reply(
|
pub(crate) fn expect_closing_echo_reply(
|
||||||
packet: &[u8],
|
packet: impl AsRef<[u8]>,
|
||||||
params: &Params,
|
params: &Params,
|
||||||
hmac_key: Option<&[u8]>,
|
hmac_key: Option<&[u8]>,
|
||||||
) -> EchoReply {
|
) -> EchoReply {
|
||||||
let reply =
|
let reply = decode_echo_reply(packet.as_ref(), params, hmac_key)
|
||||||
decode_echo_reply(packet, params, hmac_key).expect("a closing echo reply must decode");
|
.expect("a closing echo reply must decode");
|
||||||
let expected = FLAG_REPLY | FLAG_CLOSE | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
|
let expected = FLAG_REPLY | FLAG_CLOSE | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
|
||||||
assert_eq!(reply.flags, expected, "closing echo reply flags");
|
assert_eq!(reply.flags, expected, "closing echo reply flags");
|
||||||
reply
|
reply
|
||||||
@@ -452,11 +453,14 @@ pub(crate) fn client_params() -> Params {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes a reply the server produced, checking it against the server's key.
|
/// Decodes a reply the server produced, checking it against the server's key.
|
||||||
pub(crate) fn decode_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> OpenReply {
|
pub(crate) fn decode_reply(packet: impl AsRef<[u8]>, hmac_key: Option<&[u8]>) -> OpenReply {
|
||||||
decode_open_reply(packet, hmac_key).expect("server reply must decode")
|
decode_open_reply(packet.as_ref(), hmac_key).expect("server reply must decode")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn expect_normal_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> OpenReply {
|
pub(crate) fn expect_normal_open_reply(
|
||||||
|
packet: impl AsRef<[u8]>,
|
||||||
|
hmac_key: Option<&[u8]>,
|
||||||
|
) -> OpenReply {
|
||||||
let reply = decode_reply(packet, hmac_key);
|
let reply = decode_reply(packet, hmac_key);
|
||||||
let expected = FLAG_OPEN | FLAG_REPLY | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
|
let expected = FLAG_OPEN | FLAG_REPLY | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
|
||||||
assert_eq!(reply.flags, expected, "normal open reply flags");
|
assert_eq!(reply.flags, expected, "normal open reply flags");
|
||||||
@@ -465,7 +469,7 @@ pub(crate) fn expect_normal_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -
|
|||||||
reply
|
reply
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn expect_no_test_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> OpenReply {
|
pub(crate) fn expect_no_test_reply(packet: impl AsRef<[u8]>, hmac_key: Option<&[u8]>) -> OpenReply {
|
||||||
let reply = decode_reply(packet, hmac_key);
|
let reply = decode_reply(packet, hmac_key);
|
||||||
let expected =
|
let expected =
|
||||||
FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
|
FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//! Which traffic class the core asks for each reply to be sent with.
|
||||||
|
//!
|
||||||
|
//! The negotiated DSCP parameter is the **raw IPv4 TOS / IPv6 Traffic Class
|
||||||
|
//! byte**, not a six-bit codepoint: the codepoint is its upper six bits and the
|
||||||
|
//! low two are ECN. Nothing here shifts, masks or rounds it, and these tests
|
||||||
|
//! exist to keep it that way.
|
||||||
|
//!
|
||||||
|
//! The clean specification's Section 20 settles the observable rules for values
|
||||||
|
//! in `0..=255`: an echo reply carries its own session's value verbatim, the
|
||||||
|
//! marking follows the session rather than the listener, and an open reply is
|
||||||
|
//! unmarked even on a listener that has already sent a marked echo reply. The
|
||||||
|
//! server-close case is covered where the maximum-duration close is tested, and
|
||||||
|
//! the socket-level proof that these bytes reach the wire lives in the runtime's
|
||||||
|
//! Linux tests.
|
||||||
|
|
||||||
|
use irtt_proto::Params;
|
||||||
|
|
||||||
|
use super::support::{
|
||||||
|
client_params, core_with_tokens, echo_request, expect_echo_reply, expect_no_test_reply,
|
||||||
|
expect_normal_open_reply, no_test_request, open_request, peer, unthrottled, ScriptedTokens,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOKEN_A: u64 = 0x7896_b6ab_8771_5213;
|
||||||
|
|
||||||
|
/// A raw byte whose low two bits are set on purpose: ECN is part of the value,
|
||||||
|
/// and a server rebuilding the byte from a codepoint would lose them.
|
||||||
|
const RAW_TRAFFIC_CLASS: i64 = 0xbb;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_open_reply_is_unmarked_whatever_the_session_negotiates() {
|
||||||
|
let requested = Params {
|
||||||
|
dscp: 0xb8,
|
||||||
|
..client_params()
|
||||||
|
};
|
||||||
|
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||||
|
|
||||||
|
let reply = core
|
||||||
|
.handle_datagram(peer(), &open_request(&requested, None))
|
||||||
|
.unwrap()
|
||||||
|
.expect("an ordinary open must be answered");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
expect_normal_open_reply(&reply, None).params.dscp,
|
||||||
|
0xb8,
|
||||||
|
"the negotiated value is returned to the client"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reply.traffic_class(),
|
||||||
|
0,
|
||||||
|
"an open reply is unmarked however the session it creates is marked"
|
||||||
|
);
|
||||||
|
|
||||||
|
let no_test = core
|
||||||
|
.handle_datagram(peer(), &no_test_request(&requested, None))
|
||||||
|
.unwrap()
|
||||||
|
.expect("a no-test open must be answered");
|
||||||
|
assert_eq!(expect_no_test_reply(&no_test, None).params.dscp, 0xb8);
|
||||||
|
assert_eq!(
|
||||||
|
no_test.traffic_class(),
|
||||||
|
0,
|
||||||
|
"a no-test reply is unmarked too"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_echo_reply_carries_the_sessions_raw_negotiated_byte() {
|
||||||
|
let requested = Params {
|
||||||
|
dscp: RAW_TRAFFIC_CLASS,
|
||||||
|
..client_params()
|
||||||
|
};
|
||||||
|
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||||
|
let open = core
|
||||||
|
.handle_datagram(peer(), &open_request(&requested, None))
|
||||||
|
.unwrap()
|
||||||
|
.expect("the session-creating open must be answered");
|
||||||
|
let negotiated = expect_normal_open_reply(&open, None).params;
|
||||||
|
|
||||||
|
let reply = core
|
||||||
|
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
|
||||||
|
.unwrap()
|
||||||
|
.expect("an admissible echo must be answered");
|
||||||
|
|
||||||
|
expect_echo_reply(&reply, &negotiated, None);
|
||||||
|
assert_eq!(
|
||||||
|
i64::from(reply.traffic_class()),
|
||||||
|
RAW_TRAFFIC_CLASS,
|
||||||
|
"the raw byte, unshifted and unmasked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_negotiated_value_that_is_not_a_byte_is_transported_unmarked() {
|
||||||
|
// `irtt-rs` policy, not observed behavior. The server deliberately accepts
|
||||||
|
// a DSCP parameter outside `0..=255` and returns and stores it unchanged;
|
||||||
|
// the clean specification records the reference host's handling of such
|
||||||
|
// values as platform-specific and explicitly not a compatibility
|
||||||
|
// requirement. A value that is not a byte cannot be a traffic-class byte,
|
||||||
|
// so the transport asks for none — rather than wrapping −1 into 255,
|
||||||
|
// truncating 256 to 0, or refusing a session the client can otherwise use.
|
||||||
|
for dscp in [-1, 256, i64::MAX] {
|
||||||
|
let requested = Params {
|
||||||
|
dscp,
|
||||||
|
..client_params()
|
||||||
|
};
|
||||||
|
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||||
|
|
||||||
|
let open = core
|
||||||
|
.handle_datagram(peer(), &open_request(&requested, None))
|
||||||
|
.unwrap()
|
||||||
|
.unwrap_or_else(|| panic!("an open requesting DSCP {dscp} must still be answered"));
|
||||||
|
let negotiated = expect_normal_open_reply(&open, None).params;
|
||||||
|
assert_eq!(negotiated.dscp, dscp, "the value is negotiated unchanged");
|
||||||
|
assert_eq!(open.traffic_class(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
core.session(TOKEN_A)
|
||||||
|
.expect("the session must be live")
|
||||||
|
.params()
|
||||||
|
.dscp,
|
||||||
|
dscp,
|
||||||
|
"and stored unchanged"
|
||||||
|
);
|
||||||
|
|
||||||
|
let reply = core
|
||||||
|
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
|
||||||
|
.unwrap()
|
||||||
|
.unwrap_or_else(|| panic!("the session must still serve echoes with DSCP {dscp}"));
|
||||||
|
assert_eq!(
|
||||||
|
expect_echo_reply(&reply, &negotiated, None).recv_count,
|
||||||
|
Some(1),
|
||||||
|
"an unmarkable session is an ordinary session in every other way"
|
||||||
|
);
|
||||||
|
assert_eq!(reply.traffic_class(), 0, "transported unmarked");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user