Merge pull request #87 from Lochnair/feat/server-negotiation-policy

feat(server): add negotiation policy controls
This commit was merged in pull request #87.
This commit is contained in:
Nils Andreas Svee authored and GitHub committed 2026-08-14 15:06:45 +07:00
commit fabd698187
14 files changed
+965 -33

No files matched your search

+1 -1
View File
@@ -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 contains the deterministic OPEN/ECHO/session core — packet admission, authentication policy, open negotiation, the bounded session table, open reply construction, echo processing with its per-session receive state and timestamps, per-session rate limiting, session lifetime with idle expiry, the maximum-duration server-initiated close, and client-initiated close — plus a reusable Tokio UDP `Server` that runs one sequential `ServerCore` per listener with caller-controlled shutdown and scheduled idle maintenance. Logical expiry remains exact on authenticated, structurally valid requests, while the runtime also reclaims expired sessions once per second when no traffic arrives. Each reply carries the raw traffic class it must be sent with, which the runtime applies to the listener before every send. A wildcard listener additionally recovers each request's local destination from packet metadata and sends that request's reply from it, on Linux, macOS and FreeBSD; a wildcard bind on a target without that path is refused at construction rather than served from a routing-table source address. Explicit-address listeners are unaffected everywhere. Echo replies fill their payload from the session's negotiated server fill; the policy and its deliberate divergences are in `crates/irtt-server/AGENTS.md`. Multiple listeners in one process, and optional timestamp and DSCP restriction controls, are not implemented. `irtt-server` is a first-class reusable server crate. It contains the deterministic OPEN/ECHO/session core — packet admission, authentication policy, open negotiation, the bounded session table, open reply construction, echo processing with its per-session receive state and timestamps, per-session rate limiting, session lifetime with idle expiry, the maximum-duration server-initiated close, and client-initiated close — plus a reusable Tokio UDP `Server` that runs one sequential `ServerCore` per listener with caller-controlled shutdown and scheduled idle maintenance. Logical expiry remains exact on authenticated, structurally valid requests, while the runtime also reclaims expired sessions once per second when no traffic arrives. Each reply carries the raw traffic class it must be sent with, which the runtime applies to the listener before every send. A wildcard listener additionally recovers each request's local destination from packet metadata and sends that request's reply from it, on Linux, macOS and FreeBSD; a wildcard bind on a target without that path is refused at construction rather than served from a routing-table source address. Explicit-address listeners are unaffected everywhere. Echo replies fill their payload from the session's negotiated server fill; the policy and its deliberate divergences are in `crates/irtt-server/AGENTS.md`. Two optional negotiation policies restrict what a session may ask the server to provide — a timestamp allowance and DSCP permission — both off by default and both settled during open negotiation rather than in the runtime. Serving multiple listeners in one process is not implemented; that, and ordinary process-level polish and observability decisions, is what remains of the server architecture.
- 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.
+18
View File
@@ -202,6 +202,24 @@ irtt-server \
--idle-timeout 30s --idle-timeout 30s
``` ```
Two optional controls restrict what the server will provide a session, and both
are off unless asked for:
```sh
irtt-server \
--bind 192.0.2.10:2112 \
--timestamp-allowance single \
--no-dscp
```
`--timestamp-allowance` takes `dual` (the default, honoring every requested
placement), `single`, which provides at most one timestamp instant and answers a
request for both receive and send timestamps with the midpoint, or `none`, which
provides no timestamps. The requested clock source is never changed, so a single
instant is still reported once per requested clock. `--no-dscp` negotiates any
requested DSCP to zero, so clients are told their echo replies will be unmarked,
and they are sent unmarked.
To require authentication, pass the shared key both sides use: To require authentication, pass the shared key both sides use:
```sh ```sh
+99 -2
View File
@@ -1,7 +1,7 @@
use std::{net::SocketAddr, time::Duration}; use std::{net::SocketAddr, time::Duration};
use clap::Parser; use clap::{Parser, ValueEnum};
use irtt_server::ServerConfig; use irtt_server::{ServerConfig, TimestampAllowance};
// Every policy option is optional. An omitted option leaves the corresponding // Every policy option is optional. An omitted option leaves the corresponding
// `ServerConfig` default in place rather than restating it here, so library and // `ServerConfig` default in place rather than restating it here, so library and
@@ -79,6 +79,46 @@ pub struct ServerArgs {
long_help = "Maximum test duration a session may negotiate. A longer request is reduced to it, and a continuous request is answered with it. Omit this flag for no maximum; a maximum of zero cannot be expressed, because a negotiated duration of zero means continuous." long_help = "Maximum test duration a session may negotiate. A longer request is reduced to it, and a continuous request is answered with it. Omit this flag for no maximum; a maximum of zero cannot be expressed, because a negotiated duration of zero means continuous."
)] )]
pub max_duration: Option<Duration>, pub max_duration: Option<Duration>,
/// How many timestamps this server will provide.
#[arg(
long,
value_name = "MODE",
value_enum,
long_help = "How many timestamps this server will provide.\n\ndual: honor a request for send, receive, both or midpoint timestamps.\nsingle: provide at most one timestamp instant; a request for both is negotiated to midpoint.\nnone: provide no timestamps at all.\n\nThe requested clock source is never changed, only which instants are reported, so a single instant is still reported once per requested clock. Omit this flag to honor every requested placement."
)]
pub timestamp_allowance: Option<TimestampAllowanceArg>,
/// Refuse to provide requested DSCP marking.
#[arg(
long,
long_help = "Refuse to provide requested traffic-class marking. Any requested DSCP is negotiated to zero, so the client is told its echo replies will be unmarked, and they are sent unmarked. The session is not refused."
)]
pub no_dscp: bool,
}
/// The command-line spelling of [`TimestampAllowance`].
///
/// A CLI-local enum rather than a `clap` derive on the library type: `irtt-server`
/// is a reusable crate and has no business depending on an argument parser.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum TimestampAllowanceArg {
/// Honor every requested timestamp placement.
Dual,
/// Provide at most one timestamp instant; both becomes midpoint.
Single,
/// Provide no timestamps.
None,
}
impl From<TimestampAllowanceArg> for TimestampAllowance {
fn from(value: TimestampAllowanceArg) -> Self {
match value {
TimestampAllowanceArg::Dual => Self::Dual,
TimestampAllowanceArg::Single => Self::Single,
TimestampAllowanceArg::None => Self::None,
}
}
} }
impl ServerArgs { impl ServerArgs {
@@ -107,6 +147,14 @@ impl ServerArgs {
if let Some(value) = self.max_duration { if let Some(value) = self.max_duration {
config = config.with_max_test_duration(value); config = config.with_max_test_duration(value);
} }
if let Some(value) = self.timestamp_allowance {
config = config.with_timestamp_allowance(value.into());
}
// A negative capability flag, so absence is the ordinary enabled state
// and the library default is what an unset flag leaves in place.
if self.no_dscp {
config = config.with_dscp_allowed(false);
}
config config
} }
} }
@@ -224,6 +272,55 @@ mod tests {
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120))); assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
} }
#[test]
fn the_capability_restrictions_map_onto_the_server_config() {
let config = bound(&["--timestamp-allowance", "single", "--no-dscp"]).server_config();
assert_eq!(
config.timestamp_allowance(),
TimestampAllowance::Single,
"a request for both timestamps is negotiated to midpoint"
);
assert!(!config.dscp_allowed());
}
#[test]
fn every_timestamp_allowance_mode_parses() {
for (argument, expected) in [
("dual", TimestampAllowance::Dual),
("single", TimestampAllowance::Single),
("none", TimestampAllowance::None),
] {
assert_eq!(
bound(&["--timestamp-allowance", argument])
.server_config()
.timestamp_allowance(),
expected,
"--timestamp-allowance {argument}"
);
}
assert!(parse(&[
"--bind",
"127.0.0.1:2112",
"--timestamp-allowance",
"midpoint"
])
.is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--timestamp-allowance", "both"]).is_err());
}
#[test]
fn the_capability_restrictions_are_off_when_their_flags_are_absent() {
// Explicitly, and not only through the whole-config comparison above:
// these are the two values that would silently change every existing
// deployment's negotiation if the CLI restated them.
let config = bound(&["--idle-timeout", "30s"]).server_config();
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Dual);
assert!(config.dscp_allowed());
}
#[test] #[test]
fn explicit_zero_policy_values_are_preserved() { fn explicit_zero_policy_values_are_preserved() {
let config = bound(&[ let config = bound(&[
+2
View File
@@ -80,6 +80,8 @@ fn canonical_server_subcommand_dispatches_to_server() {
assert!(text.contains("--bind <ADDR>")); assert!(text.contains("--bind <ADDR>"));
assert!(text.contains("--max-sessions <COUNT>")); assert!(text.contains("--max-sessions <COUNT>"));
assert!(text.contains("--idle-timeout <DURATION>")); assert!(text.contains("--idle-timeout <DURATION>"));
assert!(text.contains("--timestamp-allowance <MODE>"));
assert!(text.contains("--no-dscp"));
} }
#[cfg(not(feature = "server"))] #[cfg(not(feature = "server"))]
+43 -2
View File
@@ -138,8 +138,10 @@ effective parameters must be safe for the server to run.
- `Clock::Unspecified` alone is valid — it is just an absent tag. Only the - `Clock::Unspecified` alone is valid — it is just an absent tag. Only the
combination is refused, and only an absent tag reaches it, since an explicit combination is refused, and only an absent tag reaches it, since an explicit
zero Clock is already out of range for the decoder. zero Clock is already out of range for the decoder.
- Run the check after negotiation, so that a later policy restricting `StampAt` - Run the check after negotiation. That is what makes a configured timestamp
to none makes an omitted clock safe without this rule changing. allowance of none accept this same request: its effective `StampAt` is none, so
the omitted clock selects nothing and the session is executable. See
"Capability restrictions" below.
- Negative `Length` stays an accepted negotiated value, returned unchanged in - Negative `Length` stays an accepted negotiated value, returned unchanged in
the open reply and stored verbatim in the session. It needs no clamping to be 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 executable: `irtt-proto` floors actual echo packet sizing at the required
@@ -253,6 +255,45 @@ into `irtt-proto`, and do not duplicate the decoder's checks here.
- Payload length comes from `irtt-proto`'s layout and `echo_packet_len`, never - Payload length comes from `irtt-proto`'s layout and `echo_packet_len`, never
from `params.length`, and needs no second packet-size bound of its own. from `params.length`, and needs no second packet-size bound of its own.
## Capability restrictions
Two optional negotiation policies restrict what a session may ask this server to
*provide*. Both default to off, so a configuration that sets neither negotiates
exactly what it did before they existed. Both live in `negotiate`, with the rest
of open negotiation, and neither reaches the runtime.
- **`TimestampAllowance` is server policy, and deliberately not `StampAt`.** The
wire enum describes what a session requests and negotiates; the allowance
describes what this server is willing to hand out. Keep the two types apart,
and keep the allowance out of `irtt-proto`.
- The default is `Dual`, which honors every requested placement. `None` maps every
non-none placement to none. `Single` maps `Both` to **`Midpoint`** and leaves
every already-single placement alone — that one substitution is the only
interesting row; the rest is in the clean spec's Section 11.4.
- **`Clock` is never rewritten.** The evidence describes an allowance on timestamp
*placement*, not on clock domains, and the echo layout already carries no
timestamp field once the placement is none. So `Single` bounds reported
*instants*, not field count: on `Clock::Both` a negotiated midpoint still emits
a wall and a monotonic field. Do not document it as one field. Rewriting the clock would answer
with a session the client did not ask for.
- **Restriction runs before the effective-session executability check**, and that
ordering is load-bearing: it is what makes a request selecting timestamps with
an omitted Clock *safe* under a none allowance, where the same request is
refused under the default. Do not move the check onto the requested params.
- **DSCP permission defaults to allowed.** When disallowed, negotiation sets
`Params.dscp` to zero before the reply is encoded and before the `Session`
exists, for every requested value including ones outside `0..=255`. The open is
never refused over DSCP, and nothing is clamped or wrapped.
- **No second policy bit anywhere.** The negotiated params are the single source
of truth: the session stores zero, `OutboundDatagram`'s traffic class derives
from it as for any other zero-DSCP session, and the runtime never learns that
`ServerConfig::dscp_allowed` exists. `Params::encode` omits a zero integer, so
the reply simply carries no DSCP tag.
- **This is operator policy, not socket capability detection.** What a given host
can apply to a socket is settled per send by the runtime (see "Reply traffic
class"). Do not couple `ServerCore` negotiation to `socket2` or to runtime
capability state.
## Reply traffic class ## Reply traffic class
The core hands each reply out as an `OutboundDatagram`: the bytes plus the The core hands each reply out as an `OutboundDatagram`: the bytes plus the
+116 -3
View File
@@ -53,12 +53,41 @@ pub const DEFAULT_BURST_ALLOWANCE: u32 = 5;
/// emits no final reply. See [`ServerConfig::with_idle_timeout`]. /// emits no final reply. See [`ServerConfig::with_idle_timeout`].
pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(60); pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
/// How many timestamps a server is willing to provide.
///
/// This is **server policy**, deliberately a separate type from the wire
/// [`StampAt`](irtt_proto::StampAt) a session requests and negotiates: one
/// describes what a client asked for, the other what this server will hand out.
/// Negotiation reduces the requested `StampAt` accordingly, and only it — the
/// requested [`Clock`](irtt_proto::Clock) is never rewritten, because the clean
/// evidence records a timestamp allowance restricting timestamp *placement*, not
/// the clock domains it is read from.
///
/// See [`ServerConfig::with_timestamp_allowance`] for the mapping.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TimestampAllowance {
/// No timestamps at all: every request is negotiated to
/// [`StampAt::None`](irtt_proto::StampAt::None).
None,
/// At most one timestamp *instant*. A request for both receive and send
/// becomes [`StampAt::Midpoint`](irtt_proto::StampAt::Midpoint); every
/// placement that already names a single instant is left alone.
///
/// This restricts placement, not field count: the reply still carries one
/// field per negotiated clock domain, so a session on
/// [`Clock::Both`](irtt_proto::Clock::Both) receives both a wall and a
/// monotonic midpoint field.
Single,
/// Every requested placement is honored. This is the default, and the
/// behavior of a server that configures nothing.
#[default]
Dual,
}
/// Configuration for a [`ServerCore`](crate::ServerCore). /// Configuration for a [`ServerCore`](crate::ServerCore).
/// ///
/// Fields are private and set through consuming builder methods, so later /// Fields are private and set through consuming builder methods, so later
/// slices can add configuration without breaking construction. Optional /// slices can add configuration without breaking construction.
/// timestamp-allowance and DSCP restriction controls would arrive with the
/// slices that implement them.
/// ///
/// **Server fill is deliberately not configurable.** The policy is fixed and /// **Server fill is deliberately not configurable.** The policy is fixed and
/// safe: every valid descriptor is honored, one that cannot be parsed falls back /// safe: every valid descriptor is honored, one that cannot be parsed falls back
@@ -78,6 +107,8 @@ pub struct ServerConfig {
burst_allowance: u32, burst_allowance: u32,
idle_timeout: Duration, idle_timeout: Duration,
max_test_duration: Option<Duration>, max_test_duration: Option<Duration>,
timestamp_allowance: TimestampAllowance,
dscp_allowed: bool,
} }
impl ServerConfig { impl ServerConfig {
@@ -221,6 +252,68 @@ impl ServerConfig {
self self
} }
/// Sets how many timestamps this server is willing to provide.
///
/// A requested placement above the allowance is reduced during negotiation,
/// so the open reply is honest about what the session's echo replies will
/// carry:
///
/// | Requested | [`Dual`] | [`Single`] | [`None`] |
/// |-----------|----------|------------|----------|
/// | `None` | `None` | `None` | `None` |
/// | `Send` | `Send` | `Send` | `None` |
/// | `Receive` | `Receive` | `Receive` | `None` |
/// | `Both` | `Both` | **`Midpoint`** | `None` |
/// | `Midpoint` | `Midpoint` | `Midpoint` | `None` |
///
/// The one substitution is the observed one: under a single-timestamp
/// allowance a request for both instants is answered with the *midpoint*,
/// not with whichever of the two the server felt like keeping.
///
/// The requested [`Clock`](irtt_proto::Clock) is left exactly as it was in
/// every case. The allowance restricts which *instants* are reported, and
/// each reported instant still carries one field per negotiated clock domain
/// — [`Single`] on [`Clock::Both`](irtt_proto::Clock::Both) reports a wall
/// and a monotonic midpoint. Only [`None`] removes timestamp fields
/// outright, and it does so because the echo layout carries none once the
/// placement is [`StampAt::None`](irtt_proto::StampAt::None).
///
/// Restriction runs *before* the effective-session check, which is what
/// makes a [`None`] allowance accept a request that selected timestamps
/// without sending a Clock: such a session is refused as requested, because
/// timestamps from no clock cannot be produced, but under this policy its
/// effective placement is none and the absent clock no longer matters.
///
/// [`Dual`]: TimestampAllowance::Dual
/// [`Single`]: TimestampAllowance::Single
/// [`None`]: TimestampAllowance::None
#[must_use]
pub fn with_timestamp_allowance(mut self, timestamp_allowance: TimestampAllowance) -> Self {
self.timestamp_allowance = timestamp_allowance;
self
}
/// Sets whether this server provides the traffic-class marking a session
/// requests.
///
/// `true`, the default, negotiates the requested DSCP parameter unchanged —
/// including a raw value outside `0..=255`, which stays in the negotiated
/// parameters and is transported unmarked.
///
/// `false` negotiates it to **zero**, whatever was requested, so the reply
/// tells the client its replies will be unmarked and the session's stored
/// parameters say the same. The open is never refused over DSCP, and nothing
/// is clamped or wrapped: this is an operator policy about providing
/// traffic-class marking at all, not a judgement about a particular value.
///
/// It is also not socket capability detection. What a given host can apply
/// to a socket is a transport concern, settled per send by the runtime.
#[must_use]
pub fn with_dscp_allowed(mut self, dscp_allowed: bool) -> Self {
self.dscp_allowed = dscp_allowed;
self
}
/// The configured HMAC key, if this server authenticates. /// The configured HMAC key, if this server authenticates.
#[must_use] #[must_use]
pub fn hmac_key(&self) -> Option<&[u8]> { pub fn hmac_key(&self) -> Option<&[u8]> {
@@ -267,6 +360,19 @@ impl ServerConfig {
pub fn max_test_duration(&self) -> Option<Duration> { pub fn max_test_duration(&self) -> Option<Duration> {
self.max_test_duration self.max_test_duration
} }
/// How many timestamps this server is willing to provide.
#[must_use]
pub fn timestamp_allowance(&self) -> TimestampAllowance {
self.timestamp_allowance
}
/// Whether a requested DSCP value is negotiated as asked, rather than to
/// zero.
#[must_use]
pub fn dscp_allowed(&self) -> bool {
self.dscp_allowed
}
} }
impl Default for ServerConfig { impl Default for ServerConfig {
@@ -279,6 +385,11 @@ impl Default for ServerConfig {
/// No maximum test duration is configured, matching the reference server's /// No maximum test duration is configured, matching the reference server's
/// ordinary policy. A session is still bounded, by the table size and the /// ordinary policy. A session is still bounded, by the table size and the
/// idle timeout. /// idle timeout.
///
/// Both capability restrictions are off: every requested timestamp placement
/// is honored ([`TimestampAllowance::Dual`]) and a requested DSCP value is
/// negotiated as asked. They are opt-in, so an existing configuration
/// negotiates exactly what it did before they existed.
fn default() -> Self { fn default() -> Self {
Self { Self {
hmac_key: None, hmac_key: None,
@@ -288,6 +399,8 @@ impl Default for ServerConfig {
burst_allowance: DEFAULT_BURST_ALLOWANCE, burst_allowance: DEFAULT_BURST_ALLOWANCE,
idle_timeout: DEFAULT_IDLE_TIMEOUT, idle_timeout: DEFAULT_IDLE_TIMEOUT,
max_test_duration: None, max_test_duration: None,
timestamp_allowance: TimestampAllowance::Dual,
dscp_allowed: true,
} }
} }
} }
+9 -5
View File
@@ -733,7 +733,9 @@ fn same_endpoint(session: SocketAddr, peer: SocketAddr) -> bool {
/// during negotiation. Length and DSCP are accepted as decoded, including zero, /// during negotiation. Length and DSCP are accepted as decoded, including zero,
/// negative and out-of-byte-range values; a DSCP the socket could never carry /// negative and out-of-byte-range values; a DSCP the socket could never carry
/// is negotiated unchanged and transported unmarked instead of being refused /// is negotiated unchanged and transported unmarked instead of being refused
/// (see [`transport_traffic_class`]). Unknown tags were ignored by the decoder /// (see [`transport_traffic_class`]), and a server that disallows DSCP
/// negotiates zero rather than rejecting the request. Unknown tags were ignored
/// by the decoder
/// and are not reflected in the reply. /// and are not reflected in the reply.
fn open_params_are_admissible(decoded: &DecodedParams) -> bool { fn open_params_are_admissible(decoded: &DecodedParams) -> bool {
let admissible = |present: bool, value: i64| !present || value > 0; let admissible = |present: bool, value: i64| !present || value > 0;
@@ -783,10 +785,12 @@ fn open_params_are_admissible(decoded: &DecodedParams) -> bool {
/// default policy makes it unreachable for an acknowledged session anyway, since /// default policy makes it unreachable for an acknowledged session anyway, since
/// negotiation has already reduced the length to a representable maximum. /// negotiation has already reduced the length to a representable maximum.
/// ///
/// The check is deliberately *after* negotiation so that a later policy slice /// The check is deliberately *after* negotiation, which is what makes a
/// restricting timestamps can restrict `stamp_at` to [`StampAt::None`], at /// configured timestamp allowance of
/// which point an absent clock is safe and this predicate holds without /// [`TimestampAllowance::None`](crate::TimestampAllowance::None) accept this
/// changing. /// otherwise refused request: its effective `stamp_at` is [`StampAt::None`], so
/// the absent clock selects nothing that was going to be reported and the
/// session is executable exactly as negotiated.
/// ///
/// [`ServerError`]: crate::ServerError /// [`ServerError`]: crate::ServerError
fn negotiated_params_are_admissible(params: &Params, config: &ServerConfig) -> bool { fn negotiated_params_are_admissible(params: &Params, config: &ServerConfig) -> bool {
+19 -5
View File
@@ -59,9 +59,23 @@
//! Open handling and negotiation, session creation, normal echo processing, //! Open handling and negotiation, session creation, normal echo processing,
//! per-session rate limiting, idle expiry, the maximum-duration //! per-session rate limiting, idle expiry, the maximum-duration
//! server-initiated close, the negotiated per-session reply traffic class, //! server-initiated close, the negotiated per-session reply traffic class,
//! server fill, wildcard reply-source selection and Tokio UDP orchestration. //! server fill, the optional timestamp-allowance and DSCP capability
//! Running several listeners in one process remains a separate slice, as do //! restrictions, wildcard reply-source selection and Tokio UDP orchestration.
//! optional timestamp and DSCP restriction controls. //! Running several listeners in one process remains a separate slice.
//!
//! # Capability restrictions
//!
//! Two optional negotiation policies restrict what a session may ask this server
//! to provide, and both are off by default, so a configuration that sets neither
//! negotiates exactly what it always did.
//!
//! [`TimestampAllowance`] reduces the requested timestamp placement: `Single`
//! provides at most one timestamp instant, answering a request for both instants
//! with the midpoint, and `None` provides no timestamps at all. The requested
//! clock is never rewritten, so a single placement still reports one field per
//! negotiated clock domain. [`ServerConfig::with_dscp_allowed`] refuses to provide
//! traffic-class marking, negotiating any requested DSCP value to zero, after
//! which the session's replies leave unmarked like any other zero-DSCP session.
//! //!
//! # Server fill //! # Server fill
//! //!
@@ -93,8 +107,8 @@ mod token;
mod tests; mod tests;
pub use config::{ pub use config::{
ServerConfig, DEFAULT_BURST_ALLOWANCE, DEFAULT_IDLE_TIMEOUT, DEFAULT_MAX_PACKET_LENGTH, ServerConfig, TimestampAllowance, DEFAULT_BURST_ALLOWANCE, DEFAULT_IDLE_TIMEOUT,
DEFAULT_MAX_SESSIONS, DEFAULT_MIN_SEND_INTERVAL, DEFAULT_MAX_PACKET_LENGTH, DEFAULT_MAX_SESSIONS, DEFAULT_MIN_SEND_INTERVAL,
}; };
pub use core::{OutboundDatagram, ServerCore}; pub use core::{OutboundDatagram, ServerCore};
pub use error::ServerError; pub use error::ServerError;
+54 -3
View File
@@ -1,6 +1,9 @@
use irtt_proto::{Params, PROTOCOL_VERSION}; use irtt_proto::{Params, StampAt, PROTOCOL_VERSION};
use crate::{clock::saturating_ns, config::ServerConfig}; use crate::{
clock::saturating_ns,
config::{ServerConfig, TimestampAllowance},
};
/// Produces the parameters the server returns in an open reply and will enforce /// Produces the parameters the server returns in an open reply and will enforce
/// for the session. /// for the session.
@@ -48,7 +51,24 @@ use crate::{clock::saturating_ns, config::ServerConfig};
/// fill the session will use, which is not always what the descriptor in /// fill the session will use, which is not always what the descriptor in
/// `Params` says. The two run back to back on the same open. /// `Params` says. The two run back to back on the same open.
/// ///
/// Later policy slices may add the timestamp allowance and DSCP policy. /// **StampAt is reduced to the configured timestamp allowance**, by
/// [`restrict_stamp_at`]. The Clock is deliberately untouched: the allowance is
/// a policy about which timestamps this server provides, and the echo layout
/// carries no timestamp field once the placement is [`StampAt::None`], so there
/// is no clock to restrict as well. Rewriting it would answer with a session the
/// client did not ask for.
///
/// **DSCP is forced to zero when the server disallows it**, and otherwise
/// returned exactly as requested — including a value outside `0..=255`, which
/// stays in the negotiated parameters and is transported unmarked. Zero is a
/// negotiated value like any other and `Params::encode` simply omits it, so a
/// restricted reply carries no DSCP tag and the session's stored parameters
/// carry zero. That one value is what the runtime derives an unmarked reply
/// from; no session or transport state records the policy separately.
///
/// Both restrictions run here, before the core validates the effective session,
/// which is what makes a timestamp-disallowing server accept an open that
/// selected timestamps without sending a Clock.
/// ///
/// Negotiation is not by itself an acknowledgement: the core validates what this /// Negotiation is not by itself an acknowledgement: the core validates what this
/// function returns and silently discards an open whose effective parameters it /// function returns and silently discards an open whose effective parameters it
@@ -96,5 +116,36 @@ pub(crate) fn negotiate_params(mut requested: Params, config: &ServerConfig) ->
requested.interval_ns = idle_cap; requested.interval_ns = idle_cap;
} }
requested.stamp_at = restrict_stamp_at(requested.stamp_at, config.timestamp_allowance());
if !config.dscp_allowed() {
requested.dscp = 0;
}
requested requested
} }
/// The timestamp placement a server offering `allowance` provides for a session
/// that requested `requested`.
///
/// The mapping is the observed one, from the clean specification's Section 11.4.
/// Only one row substitutes rather than removes: under [`Single`], a request for
/// both instants is answered with [`StampAt::Midpoint`] — one instant that still
/// describes both — and not with whichever of Send or Receive an implementation
/// happened to prefer. Every already-single placement passes through, and
/// [`None`] removes all of them.
///
/// The allowance bounds reported *instants*, not fields: the clock is untouched,
/// so a negotiated midpoint on `Clock::Both` still reports one field per domain.
///
/// [`Single`]: TimestampAllowance::Single
/// [`None`]: TimestampAllowance::None
fn restrict_stamp_at(requested: StampAt, allowance: TimestampAllowance) -> StampAt {
match allowance {
TimestampAllowance::Dual => requested,
TimestampAllowance::Single => match requested {
StampAt::Both => StampAt::Midpoint,
other => other,
},
TimestampAllowance::None => StampAt::None,
}
}
+58 -1
View File
@@ -23,7 +23,7 @@ use super::support::{
open_negotiated, other_peer, peer, sample, unthrottled, ScriptedClock, ScriptedTokens, KEY, open_negotiated, other_peer, peer, sample, unthrottled, ScriptedClock, ScriptedTokens, KEY,
OTHER_KEY, OTHER_KEY,
}; };
use crate::{clock::ClockSample, ServerConfig}; use crate::{clock::ClockSample, ServerConfig, TimestampAllowance};
const TOKEN_A: u64 = 0x0102_0304_0506_0708; const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const UNKNOWN_TOKEN: u64 = 0x2122_2324_2526_2728; 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" "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"
);
}
}
+227 -3
View File
@@ -7,12 +7,13 @@
use std::time::Duration; use std::time::Duration;
use irtt_proto::Params; use irtt_proto::{Clock, Params, StampAt};
use super::support::{ 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; const TOKEN_A: u64 = 0x2f45_9a01_c37d_6e88;
@@ -166,3 +167,226 @@ fn a_zero_idle_timeout_caps_nothing() {
3_600_000_000_000 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),
&params,
);
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}");
}
}
+35 -1
View File
@@ -4,7 +4,7 @@ use super::support::{
client_params, core_with_tokens, expect_no_test_reply, no_test_request, open_request, 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, 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_A: u64 = 0x0102_0304_0506_0708;
const TOKEN_B: u64 = 0x1112_1314_1516_1718; 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); 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");
}
+59 -7
View File
@@ -1,10 +1,10 @@
use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt}; use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt, TimestampFields};
use super::support::{ use super::support::{
client_params, core_with_tokens, expect_normal_open_reply, open_request, client_params, core_with_tokens, echo_request, expect_echo_reply, expect_normal_open_reply,
open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY, 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; 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 // 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 // of range for the decoder, and a conforming client always sends a clock
// when it selects timestamps. // 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 [ for stamp_at in [
StampAt::Send, StampAt::Send,
StampAt::Receive, 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] #[test]
fn current_non_length_parameters_survive_negotiation_unchanged() { fn current_non_length_parameters_survive_negotiation_unchanged() {
// Statistics, timestamps and DSCP are the values a later restriction-policy // Statistics, timestamps and DSCP are what the capability restrictions
// slice will deliberately start changing. Pinning them now makes that a // change when they are configured; this is the default configuration, which
// visible decision rather than an accident. Version, an oversized packet // restricts none of them. Version, an oversized packet
// length, the duration maximum and the interval floor and cap are already // length, the duration maximum and the interval floor and cap are already
// restricted and have their own tests; the length here is deliberately // restricted and have their own tests; the length here is deliberately
// under the default maximum, and the configuration below leaves duration // under the default maximum, and the configuration below leaves duration
@@ -0,0 +1,225 @@
//! The server's capability restrictions as a real client sees them.
//!
//! The core tests are authoritative for the negotiated values; these exist to
//! prove the two ends agree over a socket. A restriction this server applies has
//! to arrive at `irtt-client` as one of the restrictions it already models: a
//! strict client refuses the changed value, and a loose one accepts it, records
//! it and runs the session. Nothing in the client changed for this — that is
//! what is being checked.
use std::time::Duration;
use irtt_client::{
AsyncClient, ClientConfig, NegotiationPolicy, NegotiationRestriction, OpenOutcome, SocketConfig,
};
use irtt_proto::{Clock, ReceivedStats, StampAt};
use irtt_server::{Server, ServerConfig, TimestampAllowance};
use tokio::{sync::oneshot, time::timeout};
#[tokio::test(flavor = "current_thread")]
async fn a_single_timestamp_allowance_reaches_a_strict_and_a_loose_client() {
timeout(Duration::from_secs(2), exercise_timestamp_restriction())
.await
.expect("the timestamp negotiation integration exceeded its bounded runtime");
}
async fn exercise_timestamp_restriction() {
let server = ServerConfig::default().with_timestamp_allowance(TimestampAllowance::Single);
let (server_addr, shutdown_tx, server_task) = serve(server).await;
let mut strict = client(server_addr, NegotiationPolicy::Strict).await;
let rejected = strict
.open()
.await
.expect_err("a strict client must refuse the reduced timestamp placement");
assert!(
rejected.to_string().contains("stamp-at"),
"the restriction names the timestamp placement: {rejected}"
);
let mut loose = client(server_addr, NegotiationPolicy::Loose).await;
let negotiated = match loose.open().await.unwrap() {
OpenOutcome::Started { negotiated, .. } => negotiated,
OpenOutcome::NoTestCompleted { .. } => panic!("normal client unexpectedly ran no-test"),
};
assert_eq!(
negotiated.params.stamp_at,
StampAt::Midpoint,
"a request for both instants is answered with the midpoint"
);
assert_eq!(
negotiated.params.clock,
Clock::Both,
"the clock is not restricted with it"
);
assert!(negotiated
.restrictions
.contains(&NegotiationRestriction::StampAtChanged {
requested: StampAt::Both,
negotiated: StampAt::Midpoint,
}));
probe(&mut loose).await;
loose.close().await.unwrap();
shutdown_tx.send(()).unwrap();
server_task.await.unwrap().unwrap();
}
#[tokio::test(flavor = "current_thread")]
async fn a_disallowed_dscp_reaches_a_strict_and_a_loose_client() {
timeout(Duration::from_secs(2), exercise_dscp_restriction())
.await
.expect("the DSCP negotiation integration exceeded its bounded runtime");
}
async fn exercise_dscp_restriction() {
let (server_addr, shutdown_tx, server_task) = serve(
ServerConfig::default()
.with_min_send_interval(Duration::ZERO)
.with_dscp_allowed(false),
)
.await;
// The client's configuration is a DSCP codepoint; 46 (EF) is raw byte 184 on
// the wire, and this test does not change either convention.
let with_dscp = |policy| ClientConfig {
dscp: 46,
..test_config(server_addr, policy)
};
let mut strict = AsyncClient::connect(with_dscp(NegotiationPolicy::Strict))
.await
.unwrap();
let rejected = strict
.open()
.await
.expect_err("a strict client must refuse the removed marking");
assert!(
rejected.to_string().contains("DSCP"),
"the restriction names DSCP: {rejected}"
);
let mut loose = AsyncClient::connect(with_dscp(NegotiationPolicy::Loose))
.await
.unwrap();
let negotiated = match loose.open().await.unwrap() {
OpenOutcome::Started { negotiated, .. } => negotiated,
OpenOutcome::NoTestCompleted { .. } => panic!("normal client unexpectedly ran no-test"),
};
assert_eq!(
negotiated.params.dscp, 0,
"the raw wire parameter is negotiated to zero"
);
assert!(negotiated
.restrictions
.contains(&NegotiationRestriction::DscpChanged {
requested: 46,
negotiated: 0,
}));
probe(&mut loose).await;
loose.close().await.unwrap();
shutdown_tx.send(()).unwrap();
server_task.await.unwrap().unwrap();
}
#[tokio::test(flavor = "current_thread")]
async fn a_default_server_restricts_nothing_an_ordinary_client_asks_for() {
timeout(Duration::from_secs(2), exercise_default_policy())
.await
.expect("the default policy integration exceeded its bounded runtime");
}
/// The controls are opt-in, and this is what that has to mean: an ordinary
/// client against an unconfigured server negotiates with no restriction at all,
/// under the default *strict* policy that would refuse one.
async fn exercise_default_policy() {
let (server_addr, shutdown_tx, server_task) = serve(ServerConfig::default()).await;
let mut client = AsyncClient::connect(ClientConfig {
dscp: 46,
..test_config(server_addr, NegotiationPolicy::Strict)
})
.await
.unwrap();
let negotiated = match client.open().await.unwrap() {
OpenOutcome::Started { negotiated, .. } => negotiated,
OpenOutcome::NoTestCompleted { .. } => panic!("normal client unexpectedly ran no-test"),
};
assert_eq!(negotiated.restrictions, Vec::new());
assert_eq!(negotiated.params.stamp_at, StampAt::Both);
assert_eq!(negotiated.params.clock, Clock::Both);
assert_eq!(negotiated.params.dscp, 184, "the raw byte for codepoint 46");
probe(&mut client).await;
client.close().await.unwrap();
shutdown_tx.send(()).unwrap();
server_task.await.unwrap().unwrap();
}
/// Starts one listener and returns its address, its shutdown trigger and its
/// task.
async fn serve(
config: ServerConfig,
) -> (
std::net::SocketAddr,
oneshot::Sender<()>,
tokio::task::JoinHandle<Result<(), irtt_server::ServerRuntimeError>>,
) {
let mut server = Server::bind("127.0.0.1:0".parse().unwrap(), config)
.await
.unwrap();
let server_addr = server.local_addr().unwrap();
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let task = tokio::spawn(async move {
server
.run(async {
let _ = shutdown_rx.await;
})
.await
});
(server_addr, shutdown_tx, task)
}
/// A short deterministic client requesting both timestamps from both clocks.
fn test_config(server_addr: std::net::SocketAddr, policy: NegotiationPolicy) -> ClientConfig {
ClientConfig {
server_addr: server_addr.to_string(),
duration: Some(Duration::from_secs(1)),
interval: Duration::from_millis(100),
length: 64,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
negotiation_policy: policy,
open_timeouts: vec![Duration::from_millis(200)],
socket_config: SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..SocketConfig::default()
},
probe_timeout: Duration::from_millis(200),
..ClientConfig::default()
}
}
async fn client(server_addr: std::net::SocketAddr, policy: NegotiationPolicy) -> AsyncClient {
AsyncClient::connect(test_config(server_addr, policy))
.await
.unwrap()
}
/// One probe, answered: a restricted session is an ordinary session.
async fn probe(client: &mut AsyncClient) {
client.send_probe().await.unwrap();
let events = client.recv().await.unwrap();
assert!(
events
.iter()
.any(|event| matches!(event, irtt_client::ClientEvent::EchoReply { seq: 0, .. })),
"the restricted session must answer an ordinary probe: {events:?}"
);
}