feat(server): bound negotiated packet lengths
This commit is contained in:
7 files changed
+361
-53
No files matched your search
@@ -83,13 +83,42 @@ effective parameters must be safe for the server to run.
|
||||
the open reply and stored verbatim in the session. It needs no clamping to be
|
||||
executable: `irtt-proto` floors actual echo packet sizing at the required
|
||||
protocol field block, so a negative length behaves as zero does.
|
||||
- A session's echo datagram must fit `ServerConfig::max_packet_length`, whose
|
||||
default is 65,507 bytes. That is `irtt-rs` resource policy, not an
|
||||
interoperability requirement: it is the largest IPv4 UDP payload, a
|
||||
conservative cross-family ceiling that bounds one session's echo buffer to
|
||||
roughly 64 KiB and that no normal client exceeds. It is **not** an MTU, not an
|
||||
upstream default, and says nothing about what a path will carry. Upstream's
|
||||
unlimited default is not a compatibility target.
|
||||
- A requested positive `Length` above the configured maximum is reduced to it
|
||||
during negotiation, so the reply is honest about what the server will emit.
|
||||
Zero and negative values are left exactly as requested.
|
||||
- Capping the parameter is not sufficient. After negotiation, the mandatory echo
|
||||
field block must itself fit the configured maximum or the open is silently
|
||||
refused — statistics and timestamp fields, and authentication's 16 bytes, can
|
||||
exceed a small maximum on their own. Ask `irtt_proto::echo_packet_len` with
|
||||
this server's actual HMAC mode rather than growing a second packet-size rule
|
||||
here; an unrepresentable length from it is a request rejection, not a
|
||||
`ServerError`.
|
||||
- Zero is a valid maximum and is not a synonym for unlimited: it refuses every
|
||||
session, and no-test opens with it, since both are validated against the same
|
||||
effective session.
|
||||
- The echo slice must reuse this same configured maximum to admit inbound echo
|
||||
request datagrams by their received length, before any receive state is
|
||||
mutated.
|
||||
|
||||
## Resource policy
|
||||
|
||||
Total session and resource state must stay bounded. A single unauthenticated
|
||||
datagram creates a session and opens are never deduplicated, so an unbounded
|
||||
table is a liability. Upstream's observed absence of session and per-peer bounds
|
||||
is explicitly **not** a compatibility target and must not be reproduced.
|
||||
datagram creates a session and opens are never deduplicated, so neither an
|
||||
unbounded table nor a remotely chosen packet buffer is defensible. Upstream's
|
||||
observed absence of session and per-peer bounds, and its unlimited default
|
||||
packet length, are explicitly **not** compatibility targets and must not be
|
||||
reproduced.
|
||||
|
||||
Configured bounds are trusted local policy: an operator may deliberately set an
|
||||
absurd `max_sessions` or `max_packet_length`, and that is their choice. The
|
||||
defaults are what must stay bounded, and no bound gets an unlimited setting.
|
||||
|
||||
Keep fallible preparation before irreversible state mutation: a reply is
|
||||
prepared before a session is inserted, so an internal failure cannot leave a
|
||||
|
||||
@@ -8,17 +8,36 @@
|
||||
/// server is not expected to reproduce.
|
||||
pub const DEFAULT_MAX_SESSIONS: usize = 1024;
|
||||
|
||||
/// Default bound on the executable echo datagram size of one session, in bytes.
|
||||
///
|
||||
/// This is `irtt-rs` resource policy, not an interoperability requirement and
|
||||
/// not a protocol constant. The server specification records upstream defaulting
|
||||
/// to *unlimited*, which leaves a remotely negotiated allocation unbounded; that
|
||||
/// is explicitly not a compatibility target.
|
||||
///
|
||||
/// 65,507 is the largest payload an IPv4 UDP datagram can carry, so it is the
|
||||
/// conservative ceiling above which no negotiated length could be emitted as one
|
||||
/// datagram on any address family. It bounds a session's echo buffer to roughly
|
||||
/// 64 KiB, and it is already the maximum a normal `irtt-rs` client will ask for,
|
||||
/// so ordinary traffic never meets it.
|
||||
///
|
||||
/// It is **not** an MTU and promises nothing about what a path will carry. Path
|
||||
/// and interface policy is a separate, later concern; this value exists to keep
|
||||
/// allocation bounded, not to predict fragmentation.
|
||||
pub const DEFAULT_MAX_PACKET_LENGTH: usize = 65_507;
|
||||
|
||||
/// Configuration for a [`ServerCore`](crate::ServerCore).
|
||||
///
|
||||
/// Fields are private and set through consuming builder methods, so later
|
||||
/// slices can add configuration without breaking construction. Only the
|
||||
/// settings this OPEN/session slice actually uses exist yet; parameter
|
||||
/// restriction knobs, lifetime policy and rate limits arrive with the slices
|
||||
/// that implement them.
|
||||
/// settings this OPEN/session slice actually uses exist yet; the remaining
|
||||
/// parameter restriction knobs, lifetime policy and rate limits arrive with the
|
||||
/// slices that implement them.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ServerConfig {
|
||||
hmac_key: Option<Vec<u8>>,
|
||||
max_sessions: usize,
|
||||
max_packet_length: usize,
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
@@ -45,6 +64,32 @@ impl ServerConfig {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the maximum echo datagram size a session may negotiate, in bytes.
|
||||
///
|
||||
/// This governs the *executable* echo datagram of a session, not the size of
|
||||
/// the open exchange: a requested positive Length above this value is
|
||||
/// reduced to it during negotiation, and an open whose mandatory echo field
|
||||
/// block would not fit is silently refused, since the server could not emit
|
||||
/// a compliant reply for it. Authentication counts toward that field block.
|
||||
/// The echo slice will additionally use this limit to admit inbound echo
|
||||
/// request datagrams by their received length.
|
||||
///
|
||||
/// The value is trusted local configuration, like
|
||||
/// [`with_max_sessions`](Self::with_max_sessions): an operator may
|
||||
/// deliberately raise it far past what any path carries, and that is their
|
||||
/// resource choice to make. What must stay bounded is the *default*, which
|
||||
/// is why there is deliberately no unlimited setting.
|
||||
///
|
||||
/// Zero is accepted and is not a synonym for unlimited. No session can
|
||||
/// describe an echo packet smaller than its mandatory field block, so a zero
|
||||
/// maximum silently refuses every session-creating open — and every no-test
|
||||
/// open too, since both are validated against the same effective session.
|
||||
#[must_use]
|
||||
pub fn with_max_packet_length(mut self, max_packet_length: usize) -> Self {
|
||||
self.max_packet_length = max_packet_length;
|
||||
self
|
||||
}
|
||||
|
||||
/// The configured HMAC key, if this server authenticates.
|
||||
#[must_use]
|
||||
pub fn hmac_key(&self) -> Option<&[u8]> {
|
||||
@@ -56,14 +101,22 @@ impl ServerConfig {
|
||||
pub fn max_sessions(&self) -> usize {
|
||||
self.max_sessions
|
||||
}
|
||||
|
||||
/// The maximum echo datagram size a session may negotiate, in bytes.
|
||||
#[must_use]
|
||||
pub fn max_packet_length(&self) -> usize {
|
||||
self.max_packet_length
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
/// An unauthenticated server bounded to [`DEFAULT_MAX_SESSIONS`] sessions.
|
||||
/// An unauthenticated server bounded to [`DEFAULT_MAX_SESSIONS`] sessions,
|
||||
/// each negotiating at most [`DEFAULT_MAX_PACKET_LENGTH`] bytes per echo.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hmac_key: None,
|
||||
max_sessions: DEFAULT_MAX_SESSIONS,
|
||||
max_packet_length: DEFAULT_MAX_PACKET_LENGTH,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{collections::HashMap, net::SocketAddr};
|
||||
|
||||
use irtt_proto::{
|
||||
decode_request, encode_open_reply, verify_packet_hmac, Clock, DecodedParams,
|
||||
decode_request, echo_packet_len, encode_open_reply, verify_packet_hmac, Clock, DecodedParams,
|
||||
DecodedRequestKind, OpenReply, Params, StampAt, FLAG_CLOSE, FLAG_OPEN, FLAG_REPLY,
|
||||
};
|
||||
|
||||
@@ -141,7 +141,10 @@ impl ServerCore {
|
||||
}
|
||||
// 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.
|
||||
// reply now would be worse than silence. That slice must admit an
|
||||
// echo by its received datagram length against
|
||||
// `max_packet_length` before it touches any receive state; adding
|
||||
// that branch now would only be dead code guarding nothing.
|
||||
DecodedRequestKind::Echo { .. } => Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -164,7 +167,7 @@ impl ServerCore {
|
||||
let params = negotiate_params(decoded.params, &self.config);
|
||||
// Deliberately after negotiation, and before either open path: what has
|
||||
// to be executable is the effective session, not the request.
|
||||
if !negotiated_params_are_admissible(¶ms) {
|
||||
if !negotiated_params_are_admissible(¶ms, &self.config) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -324,10 +327,12 @@ fn open_params_are_admissible(decoded: &DecodedParams) -> bool {
|
||||
/// exists to tell a client what the session would be, so it must not report a
|
||||
/// session that could not exist.
|
||||
///
|
||||
/// One rule so far: **selecting timestamps requires a specified clock.** A
|
||||
/// non-none `stamp_at` with [`Clock::Unspecified`] asks for timestamp fields
|
||||
/// from no clock at all, which lays out no timestamp field and leaves the
|
||||
/// session with a request the server cannot honor.
|
||||
/// Two rules so far.
|
||||
///
|
||||
/// **Selecting timestamps requires a specified clock.** A non-none `stamp_at`
|
||||
/// with [`Clock::Unspecified`] asks for timestamp fields from no clock at all,
|
||||
/// which lays out no timestamp field and leaves the session with a request the
|
||||
/// server cannot honor.
|
||||
///
|
||||
/// [`Clock::Unspecified`] means the Clock tag was absent — an explicit zero is
|
||||
/// already out of range for the decoder — so this cannot be reached by a
|
||||
@@ -337,10 +342,32 @@ fn open_params_are_admissible(decoded: &DecodedParams) -> bool {
|
||||
/// `stamp_at` to none would silently drop the measurement it did ask for.
|
||||
/// Neither is the server's to decide.
|
||||
///
|
||||
/// **The session's echo datagram must fit
|
||||
/// [`max_packet_length`](ServerConfig::max_packet_length).** Negotiation already
|
||||
/// reduced an oversized Length, but that only bounds what the *parameter* asks
|
||||
/// for: the mandatory field block grows with the received statistics, the
|
||||
/// timestamps and — by 16 bytes — authentication, and can exceed the configured
|
||||
/// maximum on its own. A session whose smallest compliant reply is already too
|
||||
/// large is one the server could never answer an echo for, so it is refused at
|
||||
/// open rather than acknowledged and then found unserviceable.
|
||||
///
|
||||
/// The size comes from [`echo_packet_len`], the one packet-size authority, so
|
||||
/// this cannot drift from what the echo slice will actually encode. Its
|
||||
/// unrepresentable-length error is a rejection here, not a [`ServerError`]: a
|
||||
/// pathological requested length is remote input, not an internal failure. The
|
||||
/// default policy makes it unreachable for an acknowledged session anyway, since
|
||||
/// negotiation has already reduced the length to a representable maximum.
|
||||
///
|
||||
/// The check is deliberately *after* negotiation so that a later policy slice
|
||||
/// restricting timestamps can restrict `stamp_at` to [`StampAt::None`], at
|
||||
/// which point an absent clock is safe and this predicate holds without
|
||||
/// changing.
|
||||
fn negotiated_params_are_admissible(params: &Params) -> bool {
|
||||
params.stamp_at == StampAt::None || params.clock != Clock::Unspecified
|
||||
///
|
||||
/// [`ServerError`]: crate::ServerError
|
||||
fn negotiated_params_are_admissible(params: &Params, config: &ServerConfig) -> bool {
|
||||
if params.stamp_at != StampAt::None && params.clock == Clock::Unspecified {
|
||||
return false;
|
||||
}
|
||||
echo_packet_len(config.hmac_key().is_some(), params)
|
||||
.is_ok_and(|len| len <= config.max_packet_length())
|
||||
}
|
||||
@@ -23,11 +23,13 @@
|
||||
//!
|
||||
//! # Resource policy
|
||||
//!
|
||||
//! Total session state is bounded (see [`DEFAULT_MAX_SESSIONS`]). A single
|
||||
//! unauthenticated datagram creates a session and opens are never deduplicated,
|
||||
//! so an unbounded table is a liability rather than a compatibility feature.
|
||||
//! Upstream's observed lack of session bounds is explicitly not a compatibility
|
||||
//! target.
|
||||
//! Total session state is bounded (see [`DEFAULT_MAX_SESSIONS`]), and so is the
|
||||
//! echo datagram a single session can negotiate (see
|
||||
//! [`DEFAULT_MAX_PACKET_LENGTH`]). A single unauthenticated datagram creates a
|
||||
//! session and opens are never deduplicated, so neither an unbounded table nor a
|
||||
//! remotely chosen buffer size is a compatibility feature. Upstream's observed
|
||||
//! lack of session bounds, and its unlimited default packet length, are
|
||||
//! explicitly not compatibility targets.
|
||||
//!
|
||||
//! # Current scope
|
||||
//!
|
||||
@@ -47,6 +49,6 @@ mod token;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use config::{ServerConfig, DEFAULT_MAX_SESSIONS};
|
||||
pub use config::{ServerConfig, DEFAULT_MAX_PACKET_LENGTH, DEFAULT_MAX_SESSIONS};
|
||||
pub use core::ServerCore;
|
||||
pub use error::ServerError;
|
||||
@@ -5,26 +5,46 @@ use crate::config::ServerConfig;
|
||||
/// Produces the parameters the server returns in an open reply and will enforce
|
||||
/// for the session.
|
||||
///
|
||||
/// This slice applies the minimum the specification requires: the requested
|
||||
/// known parameters, with the protocol version rewritten to
|
||||
/// [`PROTOCOL_VERSION`]. A server must return version 1 so that version-1
|
||||
/// clients accept the session; any requested value, including an absent,
|
||||
/// zero, negative or future one, is accepted and answered with 1. Detecting a
|
||||
/// version mismatch is therefore client-side only.
|
||||
/// Two restrictions apply so far.
|
||||
///
|
||||
/// Nothing else is restricted yet. A server *may* reduce or replace requested
|
||||
/// parameters, and later policy slices will add the maximum test duration
|
||||
/// clamp, minimum interval, idle-timeout interval cap, maximum packet length,
|
||||
/// timestamp allowance, DSCP policy and server-fill allow-list. This function
|
||||
/// is where they belong, which is why the configuration is threaded through
|
||||
/// even though no knob reads it yet.
|
||||
/// **Protocol version is rewritten to [`PROTOCOL_VERSION`].** A server must
|
||||
/// return version 1 so that version-1 clients accept the session; any requested
|
||||
/// value, including an absent, zero, negative or future one, is accepted and
|
||||
/// answered with 1. Detecting a version mismatch is therefore client-side only.
|
||||
///
|
||||
/// Negotiation rewriting nothing but the version does not mean every open is
|
||||
/// answered: the core validates what this function returns and silently
|
||||
/// discards an open whose effective parameters it could not execute. That is a
|
||||
/// separate step on purpose — this function decides what the session *would*
|
||||
/// be, and the core decides whether that session can exist.
|
||||
pub(crate) fn negotiate_params(mut requested: Params, _config: &ServerConfig) -> Params {
|
||||
/// **A positive Length above the configured maximum is reduced to it.** That
|
||||
/// bounds the echo datagram — and so the buffer — a remote peer can make this
|
||||
/// server negotiate, which is why it happens here rather than at send time: the
|
||||
/// value returned in the open reply has to be the one the server will actually
|
||||
/// honor. A negative Length is left exactly as requested and a zero stays zero;
|
||||
/// neither asks for space beyond the mandatory field block, which `irtt-proto`
|
||||
/// floors echo sizing at, so there is nothing to reduce and rewriting them would
|
||||
/// answer with a session the client did not ask for.
|
||||
///
|
||||
/// Those two are the whole of the current policy. A server *may* reduce or
|
||||
/// replace requested parameters, and later policy slices will add the maximum
|
||||
/// test duration clamp,
|
||||
/// minimum interval, idle-timeout interval cap, timestamp allowance, DSCP policy
|
||||
/// and server-fill allow-list.
|
||||
///
|
||||
/// Negotiation is not by itself an acknowledgement: the core validates what this
|
||||
/// function returns and silently discards an open whose effective parameters it
|
||||
/// could not execute. That is a separate step on purpose — this function decides
|
||||
/// what the session *would* be, and the core decides whether that session can
|
||||
/// exist. Capping Length here is not enough on its own, because the mandatory
|
||||
/// echo field block can exceed the configured maximum by itself.
|
||||
pub(crate) fn negotiate_params(mut requested: Params, config: &ServerConfig) -> Params {
|
||||
requested.protocol_version = PROTOCOL_VERSION;
|
||||
|
||||
// A configured maximum wider than `i64` cannot restrict anything: the wire
|
||||
// field cannot carry more than `i64::MAX` in the first place. Saturating
|
||||
// there is therefore exact, not an approximation — unlike converting the
|
||||
// requested length the other way, which would turn a value this platform
|
||||
// cannot represent into a plausible buffer size.
|
||||
let max_length = i64::try_from(config.max_packet_length()).unwrap_or(i64::MAX);
|
||||
if requested.length > max_length {
|
||||
requested.length = max_length;
|
||||
}
|
||||
|
||||
requested
|
||||
}
|
||||
@@ -98,6 +98,51 @@ fn a_no_test_open_selecting_timestamps_without_a_clock_is_silently_refused() {
|
||||
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_reports_the_reduced_packet_length() {
|
||||
// No-test answers what the session *would* be, so it has to report the same
|
||||
// reduced length a session-creating open would enforce — while still
|
||||
// creating nothing and drawing no token.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_max_packet_length(64),
|
||||
tokens.clone(),
|
||||
);
|
||||
let requested = Params {
|
||||
length: 1000,
|
||||
..client_params()
|
||||
};
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &no_test_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a reducible length must still be answered");
|
||||
|
||||
assert_eq!(expect_no_test_reply(&packet, None).params.length, 64);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1, "no-test must not draw a token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_is_refused_when_its_echo_field_block_exceeds_the_maximum() {
|
||||
// A maximum below the mandatory echo block describes a session that could
|
||||
// not exist at all, so no-test must not report one. Both open forms run the
|
||||
// same effective-parameter check before no-test branches off.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_max_packet_length(0),
|
||||
tokens.clone(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &no_test_request(&client_params(), None))
|
||||
.expect("an unservable packet size is not a server error"),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_is_still_answered_when_no_session_could_be_created() {
|
||||
// No-test consumes no capacity, so it must keep working exactly where a
|
||||
|
||||
@@ -2,9 +2,9 @@ use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, expect_normal_open_reply, open_request,
|
||||
open_request_with_raw_params, param_int, peer, ScriptedTokens,
|
||||
open_request_with_raw_params, param_int, peer, ScriptedTokens, KEY,
|
||||
};
|
||||
use crate::ServerConfig;
|
||||
use crate::{ServerConfig, DEFAULT_MAX_PACKET_LENGTH};
|
||||
|
||||
const TOKEN_A: u64 = 0x7896_b6ab_8771_5213;
|
||||
|
||||
@@ -186,10 +186,13 @@ fn selecting_timestamps_without_a_clock_is_silently_refused() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negotiation_restricts_nothing_but_the_protocol_version() {
|
||||
// These are the values a later restriction-policy slice will deliberately
|
||||
// start changing. Pinning them now makes that a visible decision rather
|
||||
// than an accident.
|
||||
fn current_non_length_parameters_survive_negotiation_unchanged() {
|
||||
// Duration, interval, statistics, timestamps, DSCP and server fill are the
|
||||
// values a later restriction-policy slice will deliberately start changing.
|
||||
// Pinning them now makes that a visible decision rather than an accident.
|
||||
// Version and an oversized packet length are already restricted, and have
|
||||
// their own tests; the length here is deliberately under the default
|
||||
// maximum so that this test says nothing about the packet-length policy.
|
||||
//
|
||||
// This is about what *negotiation* rewrites, which is a separate question
|
||||
// from whether the core will acknowledge the result: an open whose
|
||||
@@ -199,7 +202,7 @@ fn negotiation_restricts_nothing_but_the_protocol_version() {
|
||||
protocol_version: 2,
|
||||
duration_ns: 3_600_000_000_000,
|
||||
interval_ns: 1,
|
||||
length: 65_535,
|
||||
length: 1472,
|
||||
received_stats: ReceivedStats::Window,
|
||||
stamp_at: StampAt::Midpoint,
|
||||
clock: Clock::Monotonic,
|
||||
@@ -225,16 +228,145 @@ fn negotiation_restricts_nothing_but_the_protocol_version() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_positive_length_above_the_configured_maximum_is_reduced_to_it() {
|
||||
// The reduced value is what the client is told and what the session holds,
|
||||
// because it is what the server will actually emit. Both have to agree: an
|
||||
// upstream client treats a reply shorter than the negotiated length as
|
||||
// fatal, so an honest reduction at open is the only serviceable answer.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_max_packet_length(64),
|
||||
tokens.clone(),
|
||||
);
|
||||
let requested = Params {
|
||||
length: 1000,
|
||||
..client_params()
|
||||
};
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a reducible length must still be answered");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.params.length, 64);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(tokens.remaining(), 0, "one token was consumed");
|
||||
let session = core.session(TOKEN_A).expect("the session must be live");
|
||||
assert_eq!(
|
||||
session.params().length,
|
||||
64,
|
||||
"the session must enforce the reduced length"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_maximum_reduces_an_absurd_requested_length() {
|
||||
// The largest length the wire can carry is representable as a `usize` on a
|
||||
// 64-bit target, so packet sizing would happily have turned it into an
|
||||
// allocation; the configured maximum is what stops it. Restriction happens
|
||||
// during negotiation, before any sizing, so this holds on a 32-bit target
|
||||
// too — the request never reaches a conversion that could fail.
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let requested = Params {
|
||||
length: i64::MAX,
|
||||
..client_params()
|
||||
};
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("an absurd length is reduced, not refused");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.params.length, DEFAULT_MAX_PACKET_LENGTH as i64);
|
||||
let session = core.session(TOKEN_A).expect("the session must be live");
|
||||
assert_eq!(session.params().length, DEFAULT_MAX_PACKET_LENGTH as i64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_whose_echo_field_block_exceeds_the_maximum_is_silently_refused() {
|
||||
// Length itself satisfies the policy — zero asks for nothing beyond the
|
||||
// mandatory fields — but the received-statistics fields alone make the
|
||||
// smallest compliant echo reply larger than 20 bytes. Capping the Length
|
||||
// parameter is therefore not enough on its own: this session could never be
|
||||
// answered, so it is never acknowledged.
|
||||
let requested = Params {
|
||||
protocol_version: 1,
|
||||
length: 0,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::None,
|
||||
clock: Clock::Unspecified,
|
||||
..Params::default()
|
||||
};
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_max_packet_length(20),
|
||||
tokens.clone(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.expect("an unservable packet size is not a server error"),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_counts_toward_the_executable_echo_size() {
|
||||
// An authentication field adds 16 bytes to the mandatory echo block, so a
|
||||
// maximum between the two floors admits the same minimal session on an
|
||||
// unauthenticated server and refuses it on an authenticated one. The sizes
|
||||
// themselves come from `irtt_proto`; what this pins is that the server asks
|
||||
// about its own authentication mode rather than assuming the smaller floor.
|
||||
let minimal = Params {
|
||||
protocol_version: 1,
|
||||
..Params::default()
|
||||
};
|
||||
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut authenticated = core_with_tokens(
|
||||
ServerConfig::default()
|
||||
.with_hmac_key(KEY)
|
||||
.with_max_packet_length(31),
|
||||
tokens.clone(),
|
||||
);
|
||||
assert_eq!(
|
||||
authenticated
|
||||
.handle_datagram(peer(), &open_request(&minimal, Some(KEY)))
|
||||
.expect("an unservable packet size is not a server error"),
|
||||
None
|
||||
);
|
||||
assert_eq!(authenticated.session_count(), 0);
|
||||
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
|
||||
|
||||
let mut unauthenticated = core_with_tokens(
|
||||
ServerConfig::default().with_max_packet_length(31),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
let packet = unauthenticated
|
||||
.handle_datagram(peer(), &open_request(&minimal, None))
|
||||
.unwrap()
|
||||
.expect("the same session fits without an authentication field");
|
||||
expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(unauthenticated.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_and_out_of_range_length_and_dscp_survive_negotiation() {
|
||||
// Length and DSCP are accepted as decoded in this slice, including values a
|
||||
// socket could never carry. Restricting a DSCP the server cannot apply is a
|
||||
// later policy decision, not an admission rule.
|
||||
// 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
|
||||
// carry. Restricting a DSCP the server cannot apply is a later policy
|
||||
// decision, not an admission rule.
|
||||
//
|
||||
// A negative length in particular stays negative in both the reply and the
|
||||
// stored session. It does not need clamping here to be executable: echo
|
||||
// packet sizing in `irtt-proto` floors it at the required field block, so
|
||||
// the session this creates can be run as negotiated.
|
||||
// stored session; the packet-length policy reduces oversized *positive*
|
||||
// lengths and touches nothing else. It does not need clamping here to be
|
||||
// executable: echo packet sizing in `irtt-proto` floors it at the required
|
||||
// field block, so the session this creates can be run as negotiated.
|
||||
for (length, dscp) in [(-1, -1), (0, 0), (1472, 184), (-4096, 512)] {
|
||||
let requested = Params {
|
||||
protocol_version: 1,
|
||||
|
||||
Reference in new issue
Block a user