feat(server): add negotiation policy controls
This commit is contained in:
10 files changed
+831
-30
No files matched your search
@@ -23,7 +23,7 @@ use super::support::{
|
||||
open_negotiated, other_peer, peer, sample, unthrottled, ScriptedClock, ScriptedTokens, KEY,
|
||||
OTHER_KEY,
|
||||
};
|
||||
use crate::{clock::ClockSample, ServerConfig};
|
||||
use crate::{clock::ClockSample, ServerConfig, TimestampAllowance};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const UNKNOWN_TOKEN: u64 = 0x2122_2324_2526_2728;
|
||||
@@ -759,3 +759,60 @@ fn a_reply_payload_is_server_generated_and_never_reflects_the_request() {
|
||||
"the region carries the server's default fill"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restricted_session_emits_the_restricted_timestamps_and_not_the_requested_ones() {
|
||||
// Timestamp generation follows the session's *negotiated* placement, which
|
||||
// is the point of restricting it during negotiation: a client asking for
|
||||
// both instants from both clocks gets one midpoint field per clock under a
|
||||
// single-timestamp allowance, and nothing at all under none — with the reply
|
||||
// laid out and sized from the restriction either way, not from the request.
|
||||
for (allowance, expected_stamp_at, expected, expected_len) in [
|
||||
(
|
||||
TimestampAllowance::Single,
|
||||
StampAt::Midpoint,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100),
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
32,
|
||||
),
|
||||
(
|
||||
TimestampAllowance::None,
|
||||
StampAt::None,
|
||||
TimestampFields::default(),
|
||||
16,
|
||||
),
|
||||
] {
|
||||
let mut core = core_with_sources(
|
||||
unthrottled().with_timestamp_allowance(allowance),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(1_200, 10_300)]),
|
||||
);
|
||||
let requested = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &requested, None);
|
||||
assert_eq!(negotiated.stamp_at, expected_stamp_at, "{allowance:?}");
|
||||
assert_eq!(negotiated.clock, Clock::Both, "{allowance:?}");
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{allowance:?}: an admissible echo must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
// Equality over the whole struct, so a leftover receive or send field is
|
||||
// a failure as much as a missing midpoint one.
|
||||
assert_eq!(reply.timestamps, expected, "{allowance:?}");
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
echo_packet_len(false, &negotiated).unwrap(),
|
||||
"{allowance:?}: the restricted layout's length"
|
||||
);
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
expected_len,
|
||||
"{allowance:?}: no empty slot is kept for a restricted field"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,13 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_proto::Params;
|
||||
use irtt_proto::{Clock, Params, StampAt};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, expect_normal_open_reply, open_request, peer, ScriptedTokens,
|
||||
client_params, core_with_tokens, echo_request, expect_echo_reply, expect_normal_open_reply,
|
||||
open_request, peer, unthrottled, ScriptedTokens,
|
||||
};
|
||||
use crate::ServerConfig;
|
||||
use crate::{ServerConfig, TimestampAllowance};
|
||||
|
||||
const TOKEN_A: u64 = 0x2f45_9a01_c37d_6e88;
|
||||
|
||||
@@ -166,3 +167,226 @@ fn a_zero_idle_timeout_caps_nothing() {
|
||||
3_600_000_000_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_configuration_restricts_neither_capability() {
|
||||
// Both controls are opt-in, so an existing configuration negotiates exactly
|
||||
// what it did before they existed.
|
||||
let config = ServerConfig::default();
|
||||
|
||||
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Dual);
|
||||
assert!(config.dscp_allowed());
|
||||
assert_eq!(TimestampAllowance::default(), TimestampAllowance::Dual);
|
||||
|
||||
let restricted = config
|
||||
.clone()
|
||||
.with_timestamp_allowance(TimestampAllowance::Single)
|
||||
.with_dscp_allowed(false);
|
||||
assert_eq!(restricted.timestamp_allowance(), TimestampAllowance::Single);
|
||||
assert!(!restricted.dscp_allowed());
|
||||
assert_ne!(
|
||||
restricted, config,
|
||||
"the policy is part of the configuration"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requested_stamp_at_is_reduced_to_the_configured_allowance() {
|
||||
// The whole observed mapping, driven through real opens and read out of the
|
||||
// reply, because the reply is what a client enforces its own restriction
|
||||
// policy against. Only one row substitutes rather than removes: a request
|
||||
// for both instants under a single-timestamp allowance is answered with the
|
||||
// *midpoint*, which still describes both, rather than with whichever of the
|
||||
// two the server preferred.
|
||||
for (requested, allowance, expected) in [
|
||||
(StampAt::None, TimestampAllowance::Dual, StampAt::None),
|
||||
(StampAt::None, TimestampAllowance::Single, StampAt::None),
|
||||
(StampAt::None, TimestampAllowance::None, StampAt::None),
|
||||
(StampAt::Send, TimestampAllowance::Dual, StampAt::Send),
|
||||
(StampAt::Send, TimestampAllowance::Single, StampAt::Send),
|
||||
(StampAt::Send, TimestampAllowance::None, StampAt::None),
|
||||
(StampAt::Receive, TimestampAllowance::Dual, StampAt::Receive),
|
||||
(
|
||||
StampAt::Receive,
|
||||
TimestampAllowance::Single,
|
||||
StampAt::Receive,
|
||||
),
|
||||
(StampAt::Receive, TimestampAllowance::None, StampAt::None),
|
||||
(StampAt::Both, TimestampAllowance::Dual, StampAt::Both),
|
||||
(StampAt::Both, TimestampAllowance::Single, StampAt::Midpoint),
|
||||
(StampAt::Both, TimestampAllowance::None, StampAt::None),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
TimestampAllowance::Dual,
|
||||
StampAt::Midpoint,
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
TimestampAllowance::Single,
|
||||
StampAt::Midpoint,
|
||||
),
|
||||
(StampAt::Midpoint, TimestampAllowance::None, StampAt::None),
|
||||
] {
|
||||
let params = Params {
|
||||
stamp_at: requested,
|
||||
clock: Clock::Both,
|
||||
..client_params()
|
||||
};
|
||||
let negotiated = negotiated(
|
||||
ServerConfig::default().with_timestamp_allowance(allowance),
|
||||
¶ms,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
negotiated.stamp_at, expected,
|
||||
"{requested:?} under {allowance:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated.clock,
|
||||
Clock::Both,
|
||||
"{requested:?} under {allowance:?}: the allowance restricts placement, not the clock"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timestamp_restriction_never_rewrites_the_requested_clock() {
|
||||
// The clean evidence describes a timestamp *allowance*, and says nothing
|
||||
// about restricting clock domains. A server that quietly changed the clock
|
||||
// too would report a session the client never asked for — and a client
|
||||
// enforcing strict negotiation would refuse an otherwise serviceable one.
|
||||
let single = negotiated(
|
||||
ServerConfig::default().with_timestamp_allowance(TimestampAllowance::Single),
|
||||
&Params {
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Wall,
|
||||
..client_params()
|
||||
},
|
||||
);
|
||||
assert_eq!(single.stamp_at, StampAt::Midpoint);
|
||||
assert_eq!(single.clock, Clock::Wall);
|
||||
|
||||
let none = negotiated(
|
||||
ServerConfig::default().with_timestamp_allowance(TimestampAllowance::None),
|
||||
&Params {
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..client_params()
|
||||
},
|
||||
);
|
||||
assert_eq!(none.stamp_at, StampAt::None);
|
||||
assert_eq!(
|
||||
none.clock,
|
||||
Clock::Both,
|
||||
"the clock tag stays exactly as requested, selecting nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allowed_dscp_is_negotiated_stored_and_transported_unchanged() {
|
||||
// The default, and the behavioral compatibility this policy must not
|
||||
// disturb: the raw byte reaches the reply, the session and the transport.
|
||||
let requested = Params {
|
||||
dscp: 0xbb,
|
||||
..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 open must be answered");
|
||||
|
||||
let negotiated = expect_normal_open_reply(&open, None).params;
|
||||
assert_eq!(negotiated.dscp, 0xbb);
|
||||
assert_eq!(
|
||||
core.session(TOKEN_A)
|
||||
.expect("the session must be live")
|
||||
.params()
|
||||
.dscp,
|
||||
0xbb
|
||||
);
|
||||
|
||||
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()), 0xbb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disallowed_dscp_is_negotiated_to_zero_and_the_session_runs_unmarked() {
|
||||
// The open is not refused: the server simply will not provide the marking,
|
||||
// and says so in the value it returns. Nothing downstream needs a second
|
||||
// record of the policy — the negotiated zero is what the transport reads.
|
||||
let requested = Params {
|
||||
dscp: 0xb8,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(
|
||||
unthrottled().with_dscp_allowed(false),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
let open = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a disallowed DSCP must not refuse the open");
|
||||
|
||||
let negotiated = expect_normal_open_reply(&open, None).params;
|
||||
assert_eq!(negotiated.dscp, 0, "restricted to zero");
|
||||
assert_eq!(
|
||||
core.session(TOKEN_A)
|
||||
.expect("the session must be live")
|
||||
.params()
|
||||
.dscp,
|
||||
0,
|
||||
"and stored as the value the session will be run with"
|
||||
);
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an unmarked session is an ordinary session");
|
||||
expect_echo_reply(&reply, &negotiated, None);
|
||||
assert_eq!(reply.traffic_class(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disallowed_dscp_restricts_a_value_no_socket_could_carry_too() {
|
||||
// Restriction runs during negotiation, before the transport's
|
||||
// "out-of-range means unmarked" fallback is relevant at all: these values
|
||||
// never reach the session, so the reply is honest rather than merely
|
||||
// harmless.
|
||||
for dscp in [-1, 256, i64::MAX] {
|
||||
let requested = Params {
|
||||
dscp,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(
|
||||
unthrottled().with_dscp_allowed(false),
|
||||
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, 0, "requested {dscp}");
|
||||
assert_eq!(
|
||||
core.session(TOKEN_A)
|
||||
.expect("the session must be live")
|
||||
.params()
|
||||
.dscp,
|
||||
0,
|
||||
"requested {dscp}"
|
||||
);
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the session must serve echoes after restricting {dscp}"));
|
||||
expect_echo_reply(&reply, &negotiated, None);
|
||||
assert_eq!(reply.traffic_class(), 0, "requested {dscp}");
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use super::support::{
|
||||
client_params, core_with_tokens, expect_no_test_reply, no_test_request, open_request,
|
||||
open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY,
|
||||
};
|
||||
use crate::ServerConfig;
|
||||
use crate::{ServerConfig, TimestampAllowance};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||
@@ -222,3 +222,37 @@ fn an_empty_no_test_open_is_answered() {
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_reports_the_restricted_capabilities() {
|
||||
// No-test exists to tell a client what the session would be, so it reports
|
||||
// exactly what the same open would have negotiated under this policy —
|
||||
// while still creating nothing and drawing no token. Capability policy is
|
||||
// not skipped merely because no measurement session follows.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default()
|
||||
.with_timestamp_allowance(TimestampAllowance::Single)
|
||||
.with_dscp_allowed(false),
|
||||
tokens.clone(),
|
||||
);
|
||||
let requested = Params {
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
dscp: 184,
|
||||
..client_params()
|
||||
};
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &no_test_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("a no-test open must be answered under a restricting policy");
|
||||
|
||||
let reply = expect_no_test_reply(&packet, None);
|
||||
assert_eq!(reply.params.stamp_at, StampAt::Midpoint);
|
||||
assert_eq!(reply.params.clock, Clock::Both, "the clock is untouched");
|
||||
assert_eq!(reply.params.dscp, 0);
|
||||
assert_eq!(reply.token, 0);
|
||||
assert_eq!(core.session_count(), 0, "no-test creates no session");
|
||||
assert_eq!(tokens.remaining(), 1, "no-test must not draw a token");
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt};
|
||||
use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt, TimestampFields};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, expect_normal_open_reply, open_request,
|
||||
open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY,
|
||||
client_params, core_with_tokens, echo_request, expect_echo_reply, expect_normal_open_reply,
|
||||
open_request, open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY,
|
||||
};
|
||||
use crate::{ServerConfig, DEFAULT_MAX_PACKET_LENGTH};
|
||||
use crate::{ServerConfig, TimestampAllowance, DEFAULT_MAX_PACKET_LENGTH};
|
||||
|
||||
const TOKEN_A: u64 = 0x7896_b6ab_8771_5213;
|
||||
|
||||
@@ -154,6 +154,10 @@ fn selecting_timestamps_without_a_clock_is_silently_refused() {
|
||||
// Only an *absent* Clock tag reaches this: an explicit zero is already out
|
||||
// of range for the decoder, and a conforming client always sends a clock
|
||||
// when it selects timestamps.
|
||||
//
|
||||
// A server whose timestamp allowance is none accepts the very same request,
|
||||
// because then no timestamp is negotiated to be unproducible; see
|
||||
// `a_timestamp_disallowing_server_accepts_a_request_that_omitted_its_clock`.
|
||||
for stamp_at in [
|
||||
StampAt::Send,
|
||||
StampAt::Receive,
|
||||
@@ -189,11 +193,59 @@ fn selecting_timestamps_without_a_clock_is_silently_refused() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timestamp_disallowing_server_accepts_a_request_that_omitted_its_clock() {
|
||||
// The same request the test above refuses, against a server that provides no
|
||||
// timestamps at all. The restriction runs before the effective-session
|
||||
// check, so `stamp_at` is already none by the time executability is judged:
|
||||
// the absent clock then selects nothing that was going to be reported, and
|
||||
// the session is one the server can run exactly as negotiated.
|
||||
//
|
||||
// This is what the ordering exists for. Validating the *request* instead
|
||||
// would refuse an open that this policy makes perfectly serviceable.
|
||||
let requested = Params {
|
||||
clock: Clock::Unspecified,
|
||||
stamp_at: StampAt::Both,
|
||||
..client_params()
|
||||
};
|
||||
let mut core = core_with_tokens(
|
||||
unthrottled().with_timestamp_allowance(TimestampAllowance::None),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requested, None))
|
||||
.unwrap()
|
||||
.expect("an omitted clock is safe once no timestamps are negotiated");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(reply.params.stamp_at, StampAt::None);
|
||||
assert_eq!(
|
||||
reply.params.clock,
|
||||
Clock::Unspecified,
|
||||
"the absent clock tag stays absent"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
let echo = core
|
||||
.handle_datagram(
|
||||
peer(),
|
||||
&echo_request(reply.token, 0, &reply.params, &[], None),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("the session must serve echoes");
|
||||
assert_eq!(
|
||||
expect_echo_reply(&echo, &reply.params, None).timestamps,
|
||||
TimestampFields::default(),
|
||||
"no timestamps were negotiated, so the reply carries none"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_non_length_parameters_survive_negotiation_unchanged() {
|
||||
// Statistics, timestamps and DSCP 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, an oversized packet
|
||||
// Statistics, timestamps and DSCP are what the capability restrictions
|
||||
// change when they are configured; this is the default configuration, which
|
||||
// restricts none of them. Version, an oversized packet
|
||||
// length, the duration maximum and the interval floor and cap are already
|
||||
// restricted and have their own tests; the length here is deliberately
|
||||
// under the default maximum, and the configuration below leaves duration
|
||||
|
||||
Reference in new issue
Block a user