feat(server): add open session core #74

Merged
Lochnair merged 3 commits from feat/server-core-open into main 2026-08-11 18:12:42 +00:00
31 changed files with 2088 additions and 20 deletions

No files matched your search

+4 -4
View File
@@ -39,7 +39,7 @@ For compatibility questions, the verified spec and black-box observations define
## Architecture
Main boundary (`irtt-server` is planned, not yet implemented):
Main boundary:
> `irtt-proto` — wire semantics shared by client/server. `irtt-client` — client/session behavior. `irtt-server` — server/session behavior and Tokio runtime/orchestration. `irtt-stats` — statistics over client-facing events/results as currently designed. `irtt-cli` — presentation/orchestration.
@@ -49,7 +49,7 @@ Main boundary (`irtt-server` is planned, not yet implemented):
- `AsyncClient`: optional Tokio low-level adapter.
- `ManagedClientTask` / `ManagedClientHandle`: unified Tokio managed driver/control surface.
- `BlockingManagedClient`: synchronous owner using a dedicated current-thread Tokio runtime.
- `irtt-server`: planned; see "Server architecture (`irtt-server`)" below.
- `irtt-server`: exists; see "Server architecture (`irtt-server`)" below.
- `irtt-stats`: cumulative/rolling/finite statistics over client events. No wire/session state machine logic.
- `irtt-cli`: argument parsing, managed orchestration, output, summaries, TUI, diagnostics, and exit behavior. Do not duplicate protocol/session state machines here.
@@ -57,7 +57,7 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
### Server architecture (`irtt-server`)
`irtt-server` is a planned first-class reusable server crate. It does not exist yet; this section describes the intended boundary for it, not an implemented API.
`irtt-server` is a first-class reusable server crate. It currently contains the deterministic OPEN/session core — packet admission, authentication policy, open negotiation, the bounded session table and open reply construction. ECHO processing, client/server close and session lifetime remain unimplemented, as does the Tokio runtime; see `crates/irtt-server/AGENTS.md`. The rest of this section describes the boundary the crate is built to, including the parts not yet written.
- 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.
@@ -67,7 +67,7 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
- Runtime/server orchestration belongs around that core and may freely use Tokio for UDP sockets, `recv_from`/`send_to`, IPv4/IPv6 listeners, timers, session expiry, max-duration deadlines, shutdown, bounded control/config channels where useful, and `select!`-style orchestration.
- The CLI server applet, when added, should remain thin orchestration/configuration over reusable `irtt-server`, not contain a second server state machine.
- Resource policy: the server should use bounded session/resource policy. Upstream's observed lack of useful default session/per-peer bounds (see `IRTT_SERVER_PROTOCOL_SPEC.md`) is not an interoperability requirement and must not be reproduced merely for compatibility.
- Testing: once a real `irtt-server` exists, prefer it for normal compliant fake-peer behavior where practical. Keep narrow adversarial/raw peers for intentionally malformed, ambiguous, or non-compliant wire behavior.
- Testing: prefer `irtt-server` for normal compliant fake-peer behavior where practical, as far as its implemented behavior reaches. Keep narrow adversarial/raw peers for intentionally malformed, ambiguous, or non-compliant wire behavior.
## Engineering rules
Generated
+9
View File
@@ -726,6 +726,15 @@ dependencies = [
"thiserror 2.0.20",
]
[[package]]
name = "irtt-server"
version = "0.4.0"
dependencies = [
"getrandom 0.4.3",
"irtt-proto",
"thiserror 2.0.20",
]
[[package]]
name = "irtt-stats"
version = "0.4.0"
+2
View File
@@ -2,6 +2,7 @@
members = [
"crates/irtt-proto",
"crates/irtt-client",
"crates/irtt-server",
"crates/irtt-stats",
"crates/irtt-cli",
]
@@ -18,6 +19,7 @@ readme = "README.md"
[workspace.dependencies]
irtt-proto = { version = "0.4.0", path = "crates/irtt-proto" }
irtt-client = { version = "0.4.0", path = "crates/irtt-client" }
irtt-server = { version = "0.4.0", path = "crates/irtt-server" }
irtt-stats = { version = "0.4.0", path = "crates/irtt-stats" }
[profile.release]
@@ -139,6 +139,16 @@ fn params_from_config_rejects_invalid_values() {
},
"dscp",
),
(
// The unspecified clock is the absent-tag wire default, not a
// client mode; a client that needs timestamps must name a clock.
"unspecified clock",
ClientConfig {
clock: Clock::Unspecified,
..ClientConfig::default()
},
"clock must be wall, monotonic, or both",
),
(
"empty server fill",
ClientConfig {
+4
View File
@@ -77,6 +77,10 @@ pub struct ClientConfig {
/// Requested timestamp placement in echo replies.
pub stamp_at: StampAt,
/// Requested server clock sources for timestamp fields.
///
/// [`Clock::Unspecified`] is the wire default meaning "no clock tag was
/// sent" and is not a client mode; opening a session with it is rejected.
/// Request [`Clock::Wall`], [`Clock::Monotonic`], or [`Clock::Both`].
pub clock: Clock,
/// Requested DSCP codepoint.
///
+6 -1
View File
@@ -1,7 +1,7 @@
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use irtt_proto::{
decode_echo_reply, echo_packet_len, encode_request, flags, EchoReply, OpenReply, Params,
decode_echo_reply, echo_packet_len, encode_request, flags, Clock, EchoReply, OpenReply, Params,
RequestToEncode, ServerFill, TimestampFields, PROTOCOL_VERSION,
};
@@ -989,6 +989,11 @@ fn validate_protocol_config(config: &ClientConfig) -> Result<(), ClientError> {
reason: "interval must be greater than zero".to_owned(),
});
}
if config.clock == Clock::Unspecified {
return Err(ClientError::InvalidConfig {
reason: "clock must be wall, monotonic, or both".to_owned(),
});
}
if config.dscp > MAX_DSCP_CODEPOINT {
return Err(ClientError::InvalidConfig {
reason: format!("dscp must be <= {MAX_DSCP_CODEPOINT}"),
@@ -121,6 +121,7 @@ fn clock_modes_drive_reply_event_clock_fields() {
assert_timing_fields(reply.server_timing, true, true, false, false, true, true);
assert_one_way_presence(reply.one_way, true, true);
}
Clock::Unspecified => unreachable!("clients never request an unspecified clock"),
}
assert_processing_subtracted(reply);
}
@@ -127,6 +127,9 @@ fn backend_clock_smoke() {
assert!(st.send_wall_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
Clock::Unspecified => {
unreachable!("clients never request an unspecified clock")
}
}
}
other => panic!("expected EchoReply, got {other:?}"),
+7 -2
View File
@@ -13,8 +13,13 @@ use crate::{
/// header.
const MIDPOINT_COMPAT_EXTRA: usize = TIMESTAMP_SIZE;
/// Whether the negotiation selects a midpoint timestamp from exactly one clock,
/// which is the only case the upstream dual-field compatibility form applies to.
///
/// [`Clock::Unspecified`] selects no clock and therefore lays out no midpoint
/// field at all, so it is not a single-clock negotiation.
fn is_midpoint_single_clock(params: &Params) -> bool {
params.stamp_at == StampAt::Midpoint && !matches!(params.clock, Clock::Both)
params.stamp_at == StampAt::Midpoint && matches!(params.clock, Clock::Wall | Clock::Monotonic)
}
/// Total datagram length of the upstream dual-midpoint compatibility form.
@@ -135,7 +140,7 @@ pub fn decode_echo_reply(
match params.clock {
Clock::Wall => (Some(wall), None),
Clock::Monotonic => (None, Some(mono)),
Clock::Both => {
Clock::Unspecified | Clock::Both => {
unreachable!("midpoint compat only applies to single-clock negotiations")
}
}
+1
View File
@@ -142,6 +142,7 @@ mod tests {
ReceivedStats::Both => RECV_COUNT_SIZE + RECV_WINDOW_SIZE,
};
let clock_count = match clock {
Clock::Unspecified => 0,
Clock::Wall | Clock::Monotonic => 1,
Clock::Both => 2,
};
+10 -1
View File
@@ -12,6 +12,12 @@
//! performs no additional validation, so callers that build `Params` manually
//! are responsible for validating those values before sending them.
//!
//! Parameters are optional on the wire, and an absent tag takes its wire
//! default — zero for every integer field, and [`Clock::Unspecified`] for the
//! clock. A receiver that must distinguish an omitted tag from one explicitly
//! encoded as zero uses [`Params::decode_with_presence`], which runs the same
//! parser and additionally reports a [`ParamPresence`].
//!
//! The crate also provides packet layout calculation, packet encoding and
//! decoding, and optional HMAC placement, computation, and verification
//! helpers.
@@ -51,7 +57,10 @@ pub use flags::*;
pub use hmac::{compute_hmac, compute_hmac_in_place, verify_hmac, verify_packet_hmac};
pub use layout::{echo_header_len, echo_packet_len, PacketLayout};
pub use open::{decode_open_reply, encode_open_reply, OpenReply};
pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt, MAX_SERVER_FILL_BYTES};
pub use params::{
Clock, DecodedParams, ParamPresence, Params, ReceivedStats, ServerFill, StampAt,
MAX_SERVER_FILL_BYTES,
};
pub use request::{
decode_request, encode_request, DecodedRequest, DecodedRequestKind, RequestToEncode,
};
+281 -10
View File
@@ -11,6 +11,11 @@ pub const MAX_SERVER_FILL_BYTES: usize = 32;
/// Higher-level callers should validate user or configuration input before
/// encoding. The normal `irtt-client` configuration path enforces
/// [`MAX_SERVER_FILL_BYTES`] for `server_fill`.
///
/// [`Params::default`] is the **wire** default: every integer field is zero and
/// [`clock`](Params::clock) is [`Clock::Unspecified`], which is what an open
/// request with an empty parameter payload means. It is not a set of sensible
/// client settings; a client builds its request from its own configuration.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Params {
pub protocol_version: i64,
@@ -63,21 +68,67 @@ impl Params {
///
/// This includes invalid enum values, malformed UTF-8, and `server_fill`
/// values longer than [`MAX_SERVER_FILL_BYTES`].
///
/// Absent parameters take their wire default, so a caller cannot tell an
/// omitted tag from one explicitly encoded as zero. Use
/// [`decode_with_presence`](Params::decode_with_presence) when that
/// distinction matters; both share one parser.
pub fn decode(input: &[u8]) -> Result<Self> {
Self::decode_with_presence(input).map(|decoded| decoded.params)
}
/// Decodes parameters and additionally reports which known tags appeared.
///
/// This is the same parser and the same validation as [`decode`], with the
/// presence of each known tag retained. A receiver needs it because the
/// protocol gives an omitted tag and an explicit zero the same value but
/// not the same meaning: an absent Duration or Interval is accepted as the
/// wire default zero, while one explicitly encoded as zero is invalid.
///
/// Presence means the tag appeared at least once. Repeated known tags keep
/// last-value-wins, and unknown tags remain ignored and untracked.
///
/// [`decode`]: Params::decode
pub fn decode_with_presence(input: &[u8]) -> Result<DecodedParams> {
let mut params = Self::default();
let mut presence = ParamPresence::default();
let mut pos = 0;
while pos < input.len() {
let (tag, used) = varint::decode_uvarint(&input[pos..])?;
pos += used;
match tag {
1 => params.protocol_version = read_int(input, &mut pos)?,
2 => params.duration_ns = read_int(input, &mut pos)?,
3 => params.interval_ns = read_int(input, &mut pos)?,
4 => params.length = read_int(input, &mut pos)?,
5 => params.received_stats = ReceivedStats::try_from(read_int(input, &mut pos)?)?,
6 => params.stamp_at = StampAt::try_from(read_int(input, &mut pos)?)?,
7 => params.clock = Clock::try_from(read_int(input, &mut pos)?)?,
8 => params.dscp = read_int(input, &mut pos)?,
1 => {
params.protocol_version = read_int(input, &mut pos)?;
presence.protocol_version = true;
}
2 => {
params.duration_ns = read_int(input, &mut pos)?;
presence.duration_ns = true;
}
3 => {
params.interval_ns = read_int(input, &mut pos)?;
presence.interval_ns = true;
}
4 => {
params.length = read_int(input, &mut pos)?;
presence.length = true;
}
5 => {
params.received_stats = ReceivedStats::try_from(read_int(input, &mut pos)?)?;
presence.received_stats = true;
}
6 => {
params.stamp_at = StampAt::try_from(read_int(input, &mut pos)?)?;
presence.stamp_at = true;
}
7 => {
params.clock = Clock::try_from(read_int(input, &mut pos)?)?;
presence.clock = true;
}
8 => {
params.dscp = read_int(input, &mut pos)?;
presence.dscp = true;
}
9 => {
let (len, used) = varint::decode_uvarint(&input[pos..])?;
pos += used;
@@ -97,6 +148,7 @@ impl Params {
.to_owned();
pos += len;
params.server_fill = Some(ServerFill { value });
presence.server_fill = true;
}
_ => {
let (_, used) = varint::decode_uvarint(&input[pos..])?;
@@ -104,10 +156,38 @@ impl Params {
}
}
}
Ok(params)
Ok(DecodedParams { params, presence })
}
}
/// Decoded parameters together with which known tags the payload carried.
///
/// Produced by [`Params::decode_with_presence`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct DecodedParams {
/// The decoded values. Absent parameters hold their wire default.
pub params: Params,
/// Which known tags appeared in the payload.
pub presence: ParamPresence,
}
/// Which known parameter tags a decoded payload carried.
///
/// A field is `true` when its tag appeared at least once, whatever value it
/// carried. Unknown tags are ignored and are not represented here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ParamPresence {
pub protocol_version: bool,
pub duration_ns: bool,
pub interval_ns: bool,
pub length: bool,
pub received_stats: bool,
pub stamp_at: bool,
pub clock: bool,
pub dscp: bool,
pub server_fill: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerFill {
pub value: String,
@@ -179,12 +259,31 @@ impl TryFrom<i64> for StampAt {
}
}
/// Which server clock sources supply timestamp fields.
///
/// # The zero value
///
/// [`Clock::Unspecified`] is the **wire default**, meaning the Clock tag was
/// absent from an open parameter payload — which is valid, and is what an empty
/// payload decodes to. It does not mean a peer may send an explicit Clock tag
/// encoding zero: [`TryFrom<i64>`](Clock::try_from) still rejects an explicit
/// zero as an invalid enum value, so this state is only ever reached by
/// omission. [`encode`](Params::encode) omits the tag for `Unspecified` rather
/// than emitting an explicit zero, which round-trips absence faithfully.
///
/// `Unspecified` selects no clock, so [`has_wall`](Clock::has_wall) and
/// [`has_mono`](Clock::has_mono) are both false for it and no timestamp field
/// is laid out. A client that wants timestamps must request a real clock; the
/// `irtt-client` configuration path rejects `Unspecified`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(i64)]
pub enum Clock {
/// Wire default for an absent Clock tag. Never produced from an explicit
/// encoded value.
#[default]
Unspecified = 0,
Wall = 1,
Monotonic = 2,
#[default]
Both = 3,
}
@@ -201,6 +300,11 @@ impl Clock {
impl TryFrom<i64> for Clock {
type Error = ProtoError;
/// Converts an **explicitly encoded** Clock value.
///
/// Zero is rejected: it is only valid as an absent tag, never as an encoded
/// one. [`Clock::Unspecified`] is therefore unreachable through this
/// conversion by design.
fn try_from(value: i64) -> Result<Self> {
match value {
1 => Ok(Self::Wall),
@@ -244,6 +348,13 @@ mod tests {
encoded
}
fn encode_int(tag: u64, value: i64) -> Vec<u8> {
let mut encoded = Vec::new();
varint::encode_uvarint(tag, &mut encoded);
varint::encode_varint(value, &mut encoded);
encoded
}
#[test]
fn params_round_trip() {
let params = Params {
2
@@ -452,6 +563,166 @@ mod tests {
value: 0,
})
);
assert_eq!(
Params::decode_with_presence(&encoded),
Err(ProtoError::InvalidEnum {
name: "Clock",
value: 0,
})
);
}
#[test]
fn explicit_clock_values_one_to_three_are_accepted() {
for (value, expected) in [(1, Clock::Wall), (2, Clock::Monotonic), (3, Clock::Both)] {
let decoded = Params::decode_with_presence(&encode_int(7, value)).unwrap();
assert_eq!(decoded.params.clock, expected);
assert!(decoded.presence.clock);
}
}
#[test]
fn empty_payload_decodes_to_wire_defaults_with_nothing_present() {
let decoded = Params::decode_with_presence(&[]).unwrap();
assert_eq!(decoded.params, Params::default());
assert_eq!(decoded.params.protocol_version, 0);
assert_eq!(decoded.params.duration_ns, 0);
assert_eq!(decoded.params.interval_ns, 0);
assert_eq!(decoded.params.length, 0);
assert_eq!(decoded.params.dscp, 0);
assert_eq!(decoded.params.received_stats, ReceivedStats::None);
assert_eq!(decoded.params.stamp_at, StampAt::None);
// The wire default clock is zero, not `Both`.
assert_eq!(decoded.params.clock, Clock::Unspecified);
assert!(!decoded.params.clock.has_wall());
assert!(!decoded.params.clock.has_mono());
assert_eq!(decoded.params.server_fill, None);
assert_eq!(decoded.presence, ParamPresence::default());
assert_eq!(
decoded.presence,
ParamPresence {
protocol_version: false,
duration_ns: false,
interval_ns: false,
length: false,
received_stats: false,
stamp_at: false,
clock: false,
dscp: false,
server_fill: false,
}
);
}
#[test]
fn absent_and_explicitly_zero_parameters_share_a_value_but_not_a_presence() {
// Duration and Interval are the pair a server must tell apart: absent is
// accepted, explicitly zero is not.
let absent = Params::decode_with_presence(&encode_int(1, 1)).unwrap();
assert_eq!(absent.params.duration_ns, 0);
assert!(!absent.presence.duration_ns);
assert_eq!(absent.params.interval_ns, 0);
assert!(!absent.presence.interval_ns);
assert_eq!(absent.params.clock, Clock::Unspecified);
assert!(!absent.presence.clock);
let explicit_duration = Params::decode_with_presence(&encode_int(2, 0)).unwrap();
assert_eq!(explicit_duration.params.duration_ns, 0);
assert!(explicit_duration.presence.duration_ns);
let explicit_interval = Params::decode_with_presence(&encode_int(3, 0)).unwrap();
assert_eq!(explicit_interval.params.interval_ns, 0);
assert!(explicit_interval.presence.interval_ns);
}
#[test]
fn every_known_tag_reports_its_own_presence() {
let mut encoded = Vec::new();
for (tag, value) in [
(1, 1),
(2, 5),
(3, 6),
(4, 7),
(5, 3),
(6, 4),
(7, 3),
(8, 184),
] {
encoded.extend_from_slice(&encode_int(tag, value));
}
encoded.extend_from_slice(&encode_server_fill_value(b"rand"));
let decoded = Params::decode_with_presence(&encoded).unwrap();
assert_eq!(
decoded.presence,
ParamPresence {
protocol_version: true,
duration_ns: true,
interval_ns: true,
length: true,
received_stats: true,
stamp_at: true,
clock: true,
dscp: true,
server_fill: true,
}
);
}
#[test]
fn unspecified_clock_is_encoded_by_omission() {
let params = Params {
protocol_version: 1,
clock: Clock::Unspecified,
..Params::default()
};
let encoded = params.encode();
assert_eq!(encoded, encode_int(1, 1));
assert!(
!encoded.windows(2).any(|bytes| bytes[0] == 7),
"an unspecified clock must omit the tag rather than encode an explicit zero"
);
assert_round_trip(params);
}
#[test]
fn decode_and_decode_with_presence_agree() {
let mut payloads = vec![
Vec::new(),
encode_int(1, 1),
encode_int(2, 0),
encode_server_fill_value(b"rand"),
];
payloads.push(
Params {
protocol_version: 1,
duration_ns: 3_000_000_000,
interval_ns: 1_000_000_000,
length: 1472,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
dscp: 184,
server_fill: Some(ServerFill {
value: "rand".to_owned(),
}),
}
.encode(),
);
// Malformed payloads must fail identically through both entry points.
payloads.push(vec![1, 0x80]);
payloads.push(encode_int(7, 0));
for payload in payloads {
assert_eq!(
Params::decode(&payload),
Params::decode_with_presence(&payload).map(|decoded| decoded.params),
"decode and decode_with_presence disagreed on {payload:02x?}"
);
}
}
#[test]
@@ -413,7 +413,7 @@ fn midpoint_dual_field_reply_is_accepted_wherever_length_identifies_it() {
// The first wire field must not be mistaken for the
// negotiated monotonic value.
Clock::Monotonic => (None, Some(222)),
Clock::Both => unreachable!(),
Clock::Unspecified | Clock::Both => unreachable!(),
};
assert_eq!(
reply.timestamps.midpoint_wall, expected_wall,
@@ -463,7 +463,7 @@ fn midpoint_dual_field_reply_of_equal_length_is_accepted_but_not_correctable() {
assert_eq!(reply.timestamps.midpoint_wall, None);
assert_eq!(reply.timestamps.midpoint_mono, Some(111));
}
Clock::Both => unreachable!(),
Clock::Unspecified | Clock::Both => unreachable!(),
}
}
}
+77
View File
@@ -0,0 +1,77 @@
# `irtt-server` guidance
Repository-wide guidance in the root `AGENTS.md` also applies here.
## Where server behavior comes from
Server wire behavior comes from the sanitized clean server documents, not from
upstream implementation material:
- `docs/protocol/IRTT_SERVER_PROTOCOL_SPEC.md`
- `docs/protocol/test-vectors/SERVER_BEHAVIORAL_VECTORS.md`
- `docs/protocol/BLACKBOX_VERIFICATION_REPORT.md`
- `docs/protocol/IRTT_CLIENT_PROTOCOL_SPEC.md` for the shared wire format
Never derive behavior from upstream `heistp/irtt` source or tests. If the clean
material does not establish a semantic, state the ambiguity instead of guessing.
## Boundaries
- Protocol behavior comes from `irtt-proto`. `irtt-server` must **not** depend
on `irtt-client` to reuse wire logic; a needed primitive belongs in
`irtt-proto`, where both sides get it.
- Do not reimplement magic validation, flag rules, HMAC placement, request
classification, token/header offsets or parameter parsing here.
- No statistics, CLI or presentation concerns.
- `SocketAddr` belongs here: endpoint identity is session state. It does not
belong in `irtt-proto`.
## Core versus runtime
- Deterministic protocol and session logic — admission, negotiation, the session
table, resource decisions, packet construction — belongs in the core.
- Socket and runtime orchestration — `UdpSocket`, IPv4/IPv6 listeners,
`recv_from`/`send_to`, timers, expiry, shutdown, `select!`, socket options —
belongs around the core.
- The server is intentionally **Tokio-native**. Tokio does not need to be
optional for this crate.
- Do not add a blocking server, an alternate-runtime variant, a transport trait,
a runtime abstraction layer, or a mirrored client-style API family. There is
no product requirement for any of them, and `ServerCore` being public is not a
promise of runtime independence.
## Rejection is silence
Malformed, unauthenticated, unsupported and policy-refused input is discarded
without a reply and without disturbing any live session. The protocol defines no
error reply, no reset and no NACK. Reserve the error type for internal failures
on the server's own side; an inbound `ProtoError` must never surface as a server
failure. Hostile UDP must leave the server running.
Do not let reply behavior reveal which stage rejected a datagram — in
particular, an authentication failure must be indistinguishable from an unknown
token.
## 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.
Keep fallible preparation before irreversible state mutation: a reply is
prepared before a session is inserted, so an internal failure cannot leave a
half-created session.
## Testing
- Drive the core with real packets built by the production encoders. Do not
maintain shadow encoders for well-formed wire data.
- Hand-build packets only where the point is a payload a compliant encoder
cannot produce — truncated varints, out-of-range enums, corrupted MACs.
- Token generation is the only nondeterministic part; tests inject a scripted
source so identity, collisions and allocation failure are assertable. Keep
that seam private.
- As the server gains behavior, prefer it over fake peers for normal compliant
behavior in other crates' tests. Keep small raw/adversarial peers for
intentionally malformed or non-compliant wire behavior.
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "irtt-server"
version = "0.4.0"
description = "IRTT-compatible Rust server library"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
keywords = ["irtt", "networking", "latency", "server"]
categories = ["network-programming"]
[dependencies]
irtt-proto.workspace = true
getrandom = "0.4.3"
thiserror = "2.0.20"
+69
View File
@@ -0,0 +1,69 @@
/// Default bound on the number of simultaneously live sessions.
///
/// This is `irtt-rs` resource policy, not an interoperability requirement. The
/// protocol has no rejection response, so a server refuses by staying silent
/// and the client sees an open timeout it already has to tolerate. Upstream was
/// observed to impose no session bound at all; the server specification
/// explicitly records that as an upstream policy choice which a compatible
/// server is not expected to reproduce.
pub const DEFAULT_MAX_SESSIONS: usize = 1024;
/// 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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerConfig {
hmac_key: Option<Vec<u8>>,
max_sessions: usize,
}
impl ServerConfig {
/// Sets the HMAC key, enabling authentication for this server.
///
/// Authentication is global: with a key configured, every request must
/// carry `FLAG_HMAC` and a valid MAC, and every reply is authenticated.
/// Without one, any request carrying `FLAG_HMAC` is dropped.
#[must_use]
pub fn with_hmac_key(mut self, key: impl Into<Vec<u8>>) -> Self {
self.hmac_key = Some(key.into());
self
}
/// Sets the maximum number of simultaneously live sessions.
///
/// Once the table holds this many sessions, a session-creating open is
/// silently dropped; nothing is evicted. Zero refuses every
/// session-creating open while still answering no-test opens, which create
/// no state. There is deliberately no unbounded setting.
#[must_use]
pub fn with_max_sessions(mut self, max_sessions: usize) -> Self {
self.max_sessions = max_sessions;
self
}
/// The configured HMAC key, if this server authenticates.
#[must_use]
pub fn hmac_key(&self) -> Option<&[u8]> {
self.hmac_key.as_deref()
}
/// The maximum number of simultaneously live sessions.
#[must_use]
pub fn max_sessions(&self) -> usize {
self.max_sessions
}
}
impl Default for ServerConfig {
/// An unauthenticated server bounded to [`DEFAULT_MAX_SESSIONS`] sessions.
fn default() -> Self {
Self {
hmac_key: None,
max_sessions: DEFAULT_MAX_SESSIONS,
}
}
}
+236
View File
@@ -0,0 +1,236 @@
use std::{collections::HashMap, net::SocketAddr};
use irtt_proto::{
decode_request, encode_open_reply, verify_packet_hmac, DecodedParams, DecodedRequestKind,
OpenReply, Params, FLAG_CLOSE, FLAG_OPEN, FLAG_REPLY,
};
use crate::{
config::ServerConfig,
error::ServerError,
negotiate::negotiate_params,
session::Session,
token::{OsTokenSource, TokenSource, TOKEN_ATTEMPTS},
};
/// The deterministic protocol and session engine of the server.
///
/// `ServerCore` owns admission, negotiation, the session table and reply
/// construction. It performs no I/O and keeps no clock: a caller hands it a
/// source endpoint and the bytes of one received datagram, and gets back the
/// bytes to send to that endpoint, or nothing. Everything it does is a pure
/// function of its state and that input, except for drawing session tokens.
///
/// The Tokio runtime slice will wrap it directly:
///
/// ```text
/// recv_from -> core.handle_datagram(peer, packet) -> Some(reply) -> send_to
/// -> None -> send nothing
/// ```
///
/// It is public as a low-level engine for a pre-1.0 crate, not as a promise of
/// runtime independence. The server is intentionally Tokio-native; this is not
/// a transport abstraction, and there is no blocking or alternate-runtime
/// counterpart.
///
/// # Scope
///
/// This slice implements open handling and session creation. Echo and close
/// requests are structurally recognized and then deliberately ignored — no
/// reply, no session mutation — until their own slices.
#[derive(Debug)]
pub struct ServerCore {
config: ServerConfig,
sessions: HashMap<u64, Session>,
tokens: Box<dyn TokenSource>,
}
impl ServerCore {
/// Creates a server core drawing session tokens from the operating system's
/// random source.
#[must_use]
pub fn new(config: ServerConfig) -> Self {
Self::with_token_source(config, Box::new(OsTokenSource))
}
pub(crate) fn with_token_source(config: ServerConfig, tokens: Box<dyn TokenSource>) -> Self {
Self {
config,
sessions: HashMap::new(),
tokens,
}
}
/// This core's configuration.
#[must_use]
pub fn config(&self) -> &ServerConfig {
&self.config
}
/// The number of live sessions.
///
/// This is the observable a caller genuinely needs — how much session state
/// the server is holding against its bound. The sessions themselves stay
/// crate-private until their shape settles.
#[must_use]
pub fn session_count(&self) -> usize {
self.sessions.len()
}
/// Looks up a live session by token.
///
/// This is a lookup by token alone and is therefore *not* an admission
/// check: a request is bound to a session only when the token matches and
/// it arrived from the session's exact endpoint.
//
// Called only by tests until echo and close processing arrive, which is
// what a token lookup is for.
#[allow(dead_code)]
pub(crate) fn session(&self, token: u64) -> Option<&Session> {
self.sessions.get(&token)
}
/// Handles one received datagram, returning the reply to send, if any.
///
/// `Ok(None)` means "answer nothing". The protocol defines no error reply,
/// no reset and no NACK, so every rejection is a silent discard and a
/// client distinguishes "rejected" from "lost" only by timing out. A
/// datagram is discarded when it is structurally invalid, carries the reply
/// flag, disagrees with this server's authentication configuration, fails
/// authentication, carries a malformed or out-of-range open parameter
/// payload, would exceed the session bound, or is a request kind this slice
/// does not implement. None of that mutates any live session.
///
/// Authentication is checked before open parameters are decoded. That
/// ordering is deliberate: a packet that fails authentication is discarded
/// without parsing untrusted parameter data, and no reply behavior may
/// reveal which stage rejected it.
///
/// # Errors
///
/// Returns [`ServerError`] only for an internal failure — the random source
/// failing, no unique token being found within the retry budget, or a
/// server-generated reply failing to encode. Rejected input is never an
/// error.
pub fn handle_datagram(
&mut self,
peer: SocketAddr,
packet: &[u8],
) -> Result<Option<Vec<u8>>, ServerError> {
let Ok(request) = decode_request(packet) else {
return Ok(None);
};
// The HMAC flag must agree with this server's configuration in both
// directions of mismatch, and the MAC itself is verified separately.
if request.hmac_present != self.config.hmac_key().is_some() {
return Ok(None);
}
if let Some(key) = self.config.hmac_key() {
if verify_packet_hmac(key, packet).is_err() {
return Ok(None);
}
}
match request.kind {
DecodedRequestKind::Open { no_test, params } => self.handle_open(peer, no_test, params),
// Not implemented in this slice. A close must not remove a session
// and an echo must not advance receive state until the slices that
// implement those behaviors; emitting a placeholder reply now would
// be worse than silence.
DecodedRequestKind::Close { .. } | DecodedRequestKind::Echo { .. } => Ok(None),
}
}
fn handle_open(
&mut self,
peer: SocketAddr,
no_test: bool,
encoded_params: &[u8],
) -> Result<Option<Vec<u8>>, ServerError> {
// The parameter decoder already rejects a truncated tag or value, a
// varint overflow, an out-of-range stats/stamp-at/clock enum, and a
// server-fill value that is oversized, short-buffered or not UTF-8.
let Ok(decoded) = Params::decode_with_presence(encoded_params) else {
return Ok(None);
};
if !open_params_are_admissible(&decoded) {
return Ok(None);
}
let params = negotiate_params(decoded.params, &self.config);
if no_test {
// No-test validates parameters without running a test: same
// negotiation, a zero token, and no state at all. It stays
// serviceable at capacity precisely because it creates nothing.
let reply = OpenReply {
flags: FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE,
token: 0,
params,
};
return self.encode_reply(&reply).map(Some);
}
if self.sessions.len() >= self.config.max_sessions() {
return Ok(None);
}
// Every fallible step runs before the table is touched, so an internal
// failure cannot leave a half-created session behind.
let token = self.allocate_token()?;
let reply = OpenReply {
flags: FLAG_OPEN | FLAG_REPLY,
token,
params,
};
let packet = self.encode_reply(&reply)?;
// The reply is encoded before its params move into the session, so
// nothing is cloned to keep both.
self.sessions
.insert(token, Session::new(peer, reply.params));
Ok(Some(packet))
}
/// Encodes a reply this server constructed. Any failure here is internal.
fn encode_reply(&self, reply: &OpenReply) -> Result<Vec<u8>, ServerError> {
encode_open_reply(reply, self.config.hmac_key())
.map_err(|source| ServerError::ReplyEncoding { source })
}
/// Draws a token that is non-zero and unique among live sessions.
///
/// Zero is reserved for no-test replies and is never issued to a session. A
/// value colliding with a live token is discarded rather than allowed to
/// overwrite that session. Both cases redraw, up to [`TOKEN_ATTEMPTS`].
fn allocate_token(&mut self) -> Result<u64, ServerError> {
for _ in 0..TOKEN_ATTEMPTS {
let token = self.tokens.next_token()?;
if token != 0 && !self.sessions.contains_key(&token) {
return Ok(token);
}
}
Err(ServerError::TokenExhausted {
attempts: TOKEN_ATTEMPTS,
})
}
}
/// Applies the open parameter rules the decoder cannot express.
///
/// Duration and Interval are accepted when absent, taking the wire default
/// zero, and rejected when explicitly present and not positive. The presence
/// distinction is why the decoder reports it: testing the value alone would
/// wrongly reject an open that simply omitted the tag, which includes the empty
/// parameter payload.
///
/// Every other known parameter is already validated or deliberately
/// unrestricted here. Protocol version is accepted at any value and rewritten
/// during negotiation. Length and DSCP are accepted as decoded, including zero,
/// negative and out-of-byte-range values; a later slice may restrict a DSCP it
/// cannot actually apply to the socket. Unknown tags were ignored by the
/// decoder and are not reflected in the reply.
fn open_params_are_admissible(decoded: &DecodedParams) -> bool {
let admissible = |present: bool, value: i64| !present || value > 0;
admissible(decoded.presence.duration_ns, decoded.params.duration_ns)
&& admissible(decoded.presence.interval_ns, decoded.params.interval_ns)
}
+40
View File
@@ -0,0 +1,40 @@
use irtt_proto::ProtoError;
use thiserror::Error;
/// An internal server failure.
///
/// This type deliberately does **not** describe rejected traffic. Malformed,
/// unauthenticated, unsupported and policy-refused datagrams are not errors:
/// the protocol defines no rejection response, so they are answered with
/// silence and reported as `Ok(None)` from
/// [`ServerCore::handle_datagram`](crate::ServerCore::handle_datagram). An
/// inbound [`ProtoError`] never reaches a caller — that is the whole point of
/// the admission boundary, which turns hostile UDP into a dropped datagram
/// rather than a failure.
///
/// Every variant here is instead something wrong on *this* side, where the
/// server could not do its job for a request it had already accepted.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ServerError {
/// The operating system's random source failed, so no session token could
/// be drawn.
#[error("the operating system random source failed: {reason}")]
RandomSource { reason: String },
/// No unique non-zero token was found within the retry budget.
///
/// Each attempt drew a fresh value and rejected it as zero or as a
/// collision with a live session. With a working random source this is
/// vanishingly unlikely; it exists so token allocation cannot spin forever
/// on a degenerate source.
#[error("no unique non-zero session token after {attempts} attempts")]
TokenExhausted { attempts: u32 },
/// A reply the server itself constructed could not be encoded.
///
/// The `ProtoError` here always describes server-generated bytes, never
/// anything a peer sent.
#[error("could not encode a server-generated reply: {source}")]
ReplyEncoding {
#[source]
source: ProtoError,
},
}
+52
View File
@@ -0,0 +1,52 @@
//! IRTT-compatible server behavior.
//!
//! This crate shares wire semantics with the client through `irtt-proto` and
//! does not depend on `irtt-client`.
//!
//! # Structure
//!
//! [`ServerCore`] is the deterministic protocol and session engine: packet
//! admission, authentication policy, open negotiation, the session table,
//! resource decisions and reply construction. It performs no I/O. Socket and
//! runtime orchestration — the UDP listener, address handling, timers, expiry
//! and shutdown — will live around it and is intentionally Tokio-native. There
//! is no blocking or alternate-runtime counterpart and no transport
//! abstraction.
//!
//! # Rejection is silence
//!
//! The protocol defines no error reply, no reset and no NACK. Malformed,
//! unauthenticated and policy-refused datagrams are discarded without a
//! response and without disturbing any live session, and a client tells
//! "rejected" from "lost" only by timing out. [`ServerError`] is therefore
//! reserved for internal failures on the server's own side.
//!
//! # 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.
//!
//! # Current scope
//!
//! Open handling and session creation only. Echo processing, client-initiated
//! close, session lifetime and expiry, and the Tokio runtime are separate
//! slices; structurally valid echo and close requests are currently accepted by
//! the admission boundary and then ignored.
#![forbid(unsafe_code)]
mod config;
mod core;
mod error;
mod negotiate;
mod session;
mod token;
#[cfg(test)]
mod tests;
pub use config::{ServerConfig, DEFAULT_MAX_SESSIONS};
pub use core::ServerCore;
pub use error::ServerError;
+24
View File
@@ -0,0 +1,24 @@
use irtt_proto::{Params, PROTOCOL_VERSION};
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.
///
/// 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.
pub(crate) fn negotiate_params(mut requested: Params, _config: &ServerConfig) -> Params {
requested.protocol_version = PROTOCOL_VERSION;
requested
}
+54
View File
@@ -0,0 +1,54 @@
use std::net::SocketAddr;
use irtt_proto::Params;
/// A live session created by an accepted open request.
///
/// This slice records only what identifies the session and what was negotiated
/// for it. Echo receive state (counts, windows, sequence tracking) and lifetime
/// state (activity, deadlines, expiry) belong to the slices that implement
/// those behaviors, and are deliberately absent rather than present as
/// placeholders.
///
/// Deliberately crate-private. The next slices are all but certain to add and
/// restructure fields here, and nothing outside the crate needs a session yet;
/// publishing internal session state before ECHO even exists would buy nothing
/// and commit us to a shape we are about to change.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Session {
peer: SocketAddr,
params: Params,
}
impl Session {
pub(crate) fn new(peer: SocketAddr, params: Params) -> Self {
Self { peer, params }
}
/// The exact source endpoint the open request arrived from.
///
/// A session is bound to this endpoint, not merely to its address: address
/// family, address, UDP source port and — for IPv6 — the scope identifier
/// all form part of its identity. Echo and close processing, once
/// implemented, must require both a token match and an exact endpoint match
/// before touching a session.
//
// Read only by tests until then. The endpoint has to be captured at open
// time — it is not recoverable later — so this slice stores state whose
// production consumer is the next one, and the tests prove it is stored
// exactly.
#[allow(dead_code)]
pub(crate) fn peer(&self) -> SocketAddr {
self.peer
}
/// The negotiated parameters the server returned for this session and will
/// enforce for it.
//
// Also awaiting its production consumer: echo replies are built from the
// negotiated params.
#[allow(dead_code)]
pub(crate) fn params(&self) -> &Params {
&self.params
}
}
+156
View File
@@ -0,0 +1,156 @@
use irtt_proto::{verify_packet_hmac, FLAG_HMAC};
use super::support::{
client_params, core_with_tokens, expect_normal_open_reply, open_request,
open_request_with_raw_params, param_int, peer, ScriptedTokens, KEY, OTHER_KEY,
};
use crate::ServerConfig;
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
/// Asserts a datagram is silently discarded, leaving no trace at all: no reply,
/// no session, and no session token drawn.
fn assert_discarded(config: ServerConfig, name: &str, packet: &[u8]) {
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(config, tokens.clone());
let reply = core
.handle_datagram(peer(), packet)
.expect("a rejected datagram is not a server error");
assert!(reply.is_none(), "{name} must be answered with silence");
assert_eq!(core.session_count(), 0, "{name} must create no session");
assert_eq!(
tokens.remaining(),
1,
"{name} must not consume session capacity or a token"
);
}
#[test]
fn an_authenticating_server_discards_everything_that_does_not_authenticate() {
let config = ServerConfig::default().with_hmac_key(KEY);
assert_discarded(
config.clone(),
"open with no MAC field at all",
&open_request(&client_params(), None),
);
assert_discarded(
config.clone(),
"open signed with a different key",
&open_request(&client_params(), Some(OTHER_KEY)),
);
let mut corrupted = open_request(&client_params(), Some(KEY));
corrupted[5] ^= 0x80;
assert_discarded(
config.clone(),
"open with one bit flipped in the MAC",
&corrupted,
);
let mut zeroed = open_request(&client_params(), Some(KEY));
zeroed[4..20].fill(0);
assert_discarded(config.clone(), "open with an all-zero MAC", &zeroed);
let mut truncated = open_request(&client_params(), Some(KEY));
truncated.drain(4..12);
assert_discarded(
config.clone(),
"open with a truncated MAC field",
&truncated,
);
let mut flag_cleared = open_request(&client_params(), Some(KEY));
flag_cleared[3] &= !FLAG_HMAC;
assert_discarded(
config,
"open with a valid MAC but the HMAC flag cleared",
&flag_cleared,
);
}
#[test]
fn a_non_authenticating_server_discards_authenticated_requests() {
assert_discarded(
ServerConfig::default(),
"authenticated open to a server with no key",
&open_request(&client_params(), Some(KEY)),
);
}
#[test]
fn an_authenticating_server_answers_a_valid_open_with_an_authenticated_reply() {
let mut core = core_with_tokens(
ServerConfig::default().with_hmac_key(KEY),
ScriptedTokens::new([TOKEN_A]),
);
let packet = core
.handle_datagram(peer(), &open_request(&client_params(), Some(KEY)))
.unwrap()
.expect("a correctly authenticated open must be answered");
let reply = expect_normal_open_reply(&packet, Some(KEY));
assert_eq!(reply.token, TOKEN_A);
assert_ne!(packet[3] & FLAG_HMAC, 0, "the reply must set FLAG_HMAC");
verify_packet_hmac(KEY, &packet).expect("the reply must carry a valid MAC");
assert_eq!(core.session_count(), 1);
}
#[test]
fn a_non_authenticating_server_answers_an_ordinary_open() {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request(&client_params(), None))
.unwrap()
.expect("an unauthenticated open must be answered");
expect_normal_open_reply(&packet, None);
assert_eq!(packet[3] & FLAG_HMAC, 0);
assert_eq!(core.session_count(), 1);
}
#[test]
fn authentication_is_checked_before_parameters_are_decoded() {
// Both of these are dropped, and neither reveals which stage rejected it.
// The pairing is the point: bad parameters under a valid MAC still drop,
// and a bad MAC drops without the parameters ever being parsed.
let config = ServerConfig::default().with_hmac_key(KEY);
let bad_params = param_int(2, 0);
assert_discarded(
config.clone(),
"authenticated open with an explicit zero duration",
&open_request_with_raw_params(&bad_params, Some(KEY)),
);
assert_discarded(
config,
"wrongly keyed open with an explicit zero duration",
&open_request_with_raw_params(&bad_params, Some(OTHER_KEY)),
);
}
#[test]
fn a_failed_authentication_leaves_a_live_session_untouched() {
let mut core = core_with_tokens(
ServerConfig::default().with_hmac_key(KEY),
ScriptedTokens::new([TOKEN_A]),
);
core.handle_datagram(peer(), &open_request(&client_params(), Some(KEY)))
.unwrap()
.expect("the session-creating open must succeed");
let session = core.session(TOKEN_A).cloned().expect("session must exist");
for bad in [
open_request(&client_params(), None),
open_request(&client_params(), Some(OTHER_KEY)),
] {
assert_eq!(core.handle_datagram(peer(), &bad).unwrap(), None);
}
assert_eq!(core.session_count(), 1);
assert_eq!(core.session(TOKEN_A), Some(&session));
}
+18
View File
@@ -0,0 +1,18 @@
//! Deterministic tests for the server core.
//!
//! The core takes a source endpoint and datagram bytes and returns reply bytes,
//! so these drive it directly; no socket is bound. Requests are built with the
//! production encoders wherever the wire form is well-formed, and by hand only
//! where the point of the test is a payload a compliant encoder cannot produce.
//!
//! Tokens come from a scripted source so session identity, collisions and
//! allocation failure are all assertable.
mod hmac;
mod no_test;
mod open;
mod params;
mod sessions;
mod support;
mod tokens;
mod unsupported;
+153
View File
@@ -0,0 +1,153 @@
use irtt_proto::{verify_packet_hmac, Params};
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, ScriptedTokens, KEY,
};
use crate::ServerConfig;
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
#[test]
fn a_no_test_open_replies_with_a_zero_token_and_creates_nothing() {
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
let packet = core
.handle_datagram(peer(), &no_test_request(&client_params(), None))
.unwrap()
.expect("a no-test open must be answered");
let reply = expect_no_test_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..client_params()
}
);
assert_eq!(core.session_count(), 0, "no-test creates no session");
assert_eq!(
tokens.remaining(),
1,
"no-test must not draw a session token"
);
}
#[test]
fn a_no_test_open_negotiates_exactly_as_a_normal_open_does() {
let requested = client_params();
let mut normal_core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let normal = normal_core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.expect("normal open must be answered");
let mut no_test_core =
core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let no_test = no_test_core
.handle_datagram(peer(), &no_test_request(&requested, None))
.unwrap()
.expect("no-test open must be answered");
assert_eq!(
expect_no_test_reply(&no_test, None).params,
super::support::decode_reply(&normal, None).params
);
}
#[test]
fn a_no_test_open_is_rejected_on_the_same_parameters_a_normal_open_is() {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let request = {
let mut packet = no_test_request(&client_params(), None);
// Replace the parameter payload with an explicit zero duration.
packet.truncate(4);
packet.extend_from_slice(&param_int(2, 0));
packet
};
assert_eq!(core.handle_datagram(peer(), &request).unwrap(), None);
assert_eq!(core.session_count(), 0);
}
#[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
// session-creating open would be refused.
let mut refuses_everything = core_with_tokens(
ServerConfig::default().with_max_sessions(0),
ScriptedTokens::new([TOKEN_A]),
);
assert_eq!(
refuses_everything
.handle_datagram(peer(), &open_request(&client_params(), None))
.unwrap(),
None,
"max_sessions 0 refuses a session-creating open"
);
let packet = refuses_everything
.handle_datagram(peer(), &no_test_request(&client_params(), None))
.unwrap()
.expect("no-test must be served at max_sessions 0");
expect_no_test_reply(&packet, None);
let mut full = core_with_tokens(
ServerConfig::default().with_max_sessions(1),
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
);
full.handle_datagram(peer(), &open_request(&client_params(), None))
.unwrap()
.expect("the first open fills the table");
assert_eq!(full.session_count(), 1);
let packet = full
.handle_datagram(peer(), &no_test_request(&client_params(), None))
.unwrap()
.expect("no-test must be served with a full session table");
expect_no_test_reply(&packet, None);
assert_eq!(full.session_count(), 1, "no-test disturbed the table");
}
#[test]
fn an_authenticated_no_test_reply_is_authenticated_and_still_creates_nothing() {
let mut core = core_with_tokens(
ServerConfig::default().with_hmac_key(KEY),
ScriptedTokens::new([TOKEN_A]),
);
let packet = core
.handle_datagram(peer(), &no_test_request(&client_params(), Some(KEY)))
.unwrap()
.expect("an authenticated no-test open must be answered");
expect_no_test_reply(&packet, Some(KEY));
verify_packet_hmac(KEY, &packet).expect("the no-test reply must carry a valid MAC");
assert_eq!(core.session_count(), 0);
}
#[test]
fn an_empty_no_test_open_is_answered() {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let mut request = open_request_with_raw_params(&[], None);
request[3] |= irtt_proto::FLAG_CLOSE;
let packet = core
.handle_datagram(peer(), &request)
.unwrap()
.expect("an empty no-test open must be answered");
let reply = expect_no_test_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..Params::default()
}
);
assert_eq!(core.session_count(), 0);
}
+197
View File
@@ -0,0 +1,197 @@
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,
};
use crate::ServerConfig;
const TOKEN_A: u64 = 0x7896_b6ab_8771_5213;
#[test]
fn an_ordinary_open_is_answered_and_creates_one_session() {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request(&client_params(), None))
.unwrap()
.expect("an ordinary open must be answered");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.token, TOKEN_A);
assert_eq!(core.session_count(), 1);
let session = core.session(TOKEN_A).expect("the session must be live");
assert_eq!(session.params(), &reply.params);
}
#[test]
fn a_session_records_the_exact_source_endpoint() {
// Address family, address, port and — where present — the IPv6 scope all
// form part of session identity, so the endpoint is stored verbatim rather
// than normalized to an address.
for endpoint in [
"198.51.100.7:41234",
"198.51.100.7:41235",
"[2001:db8::1]:2112",
"[fe80::1%3]:2112",
] {
let endpoint = endpoint.parse().unwrap();
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
core.handle_datagram(endpoint, &open_request(&client_params(), None))
.unwrap()
.expect("open must be answered");
assert_eq!(core.session(TOKEN_A).unwrap().peer(), endpoint);
}
}
#[test]
fn any_requested_protocol_version_is_accepted_and_answered_with_one() {
// Version mismatch is detected client-side only: the server rewrites the
// version and creates a normal session whatever was asked for.
for requested in [Some(1), Some(0), Some(2), Some(-1), None] {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let params = Params {
protocol_version: requested.unwrap_or(0),
..client_params()
};
let request = match requested {
// An explicit zero cannot be encoded by `Params::encode`, which
// omits zero-valued integers, so encode that tag by hand.
Some(0) => open_request_with_raw_params(&param_int(1, 0), None),
Some(_) => open_request(&params, None),
None => open_request(
&Params {
protocol_version: 0,
..params
},
None,
),
};
let packet = core
.handle_datagram(peer(), &request)
.unwrap()
.unwrap_or_else(|| panic!("protocol version {requested:?} must be answered"));
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.params.protocol_version, 1);
assert_eq!(core.session_count(), 1);
}
}
#[test]
fn an_empty_parameter_payload_is_answered_with_only_a_version() {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request_with_raw_params(&[], None))
.unwrap()
.expect("an empty parameter payload is a valid open");
// header (4) + token (8) + ProtocolVersion=1 (2). Every other parameter
// negotiates to its wire-default zero and is omitted entirely, which is
// only reachable if absent parameters really did decode as zero.
assert_eq!(packet.len(), 14);
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..Params::default()
}
);
assert_eq!(reply.params.clock, Clock::Unspecified);
assert_eq!(core.session_count(), 1);
}
#[test]
fn unknown_parameter_tags_are_ignored_and_not_reflected_in_the_reply() {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let mut payload = param_int(1, 1);
payload.extend_from_slice(&param_int(42, 7));
payload.extend_from_slice(&param_int(200, 9));
let packet = core
.handle_datagram(peer(), &open_request_with_raw_params(&payload, None))
.unwrap()
.expect("unknown tags must not prevent an open");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..Params::default()
}
);
// The reply is the same 14 bytes an empty payload produces: no unknown tag
// was copied through.
assert_eq!(packet.len(), 14);
assert_eq!(core.session_count(), 1);
}
#[test]
fn this_slice_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.
let unrestricted = Params {
protocol_version: 2,
duration_ns: 3_600_000_000_000,
interval_ns: 1,
length: 65_535,
received_stats: ReceivedStats::Window,
stamp_at: StampAt::Midpoint,
clock: Clock::Monotonic,
dscp: 184,
server_fill: Some(ServerFill {
value: "pattern:abc".to_owned(),
}),
};
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request(&unrestricted, None))
.unwrap()
.expect("open must be answered");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..unrestricted
}
);
}
#[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.
for (length, dscp) in [(-1, -1), (0, 0), (1472, 184), (-4096, 512)] {
let requested = Params {
protocol_version: 1,
length,
dscp,
..Params::default()
};
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.unwrap_or_else(|| panic!("length {length} dscp {dscp} must be answered"));
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.params.length, length);
assert_eq!(reply.params.dscp, dscp);
assert_eq!(core.session_count(), 1);
}
}
+158
View File
@@ -0,0 +1,158 @@
use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt};
use super::support::{
core_with_tokens, expect_normal_open_reply, open_request, open_request_with_raw_params,
param_int, param_server_fill, peer, ScriptedTokens,
};
use crate::ServerConfig;
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
fn reject_case(name: &str, payload: Vec<u8>) {
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
let reply = core
.handle_datagram(peer(), &open_request_with_raw_params(&payload, None))
.expect("rejected parameters are not a server error");
assert!(reply.is_none(), "{name} must be answered with silence");
assert_eq!(core.session_count(), 0, "{name} must create no session");
assert_eq!(
tokens.remaining(),
1,
"{name} must not consume a session token"
);
}
#[test]
fn malformed_or_out_of_range_parameters_are_dropped_without_a_session() {
// Truncation and overflow.
reject_case("truncated tag varint", vec![0x80]);
reject_case("truncated value varint", vec![2]);
reject_case("truncated multi-byte value", vec![2, 0x80]);
reject_case(
"varint overflow",
vec![
1, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
],
);
// Duration and Interval: present and not positive.
reject_case("explicit duration zero", param_int(2, 0));
reject_case("negative duration", param_int(2, -1));
reject_case("explicit interval zero", param_int(3, 0));
reject_case("negative interval", param_int(3, -5));
// Enum values outside their defined range.
reject_case("received stats above range", param_int(5, 4));
reject_case("negative received stats", param_int(5, -1));
reject_case("stamp at above range", param_int(6, 5));
reject_case("explicit clock zero", param_int(7, 0));
reject_case("clock above range", param_int(7, 4));
// Server fill.
reject_case(
"server fill declaring more than the payload holds",
param_server_fill(10, b"ab"),
);
reject_case(
"server fill longer than 32 bytes",
param_server_fill(33, b"0123456789abcdef0123456789abcdefx"),
);
reject_case(
"server fill that is not utf-8",
param_server_fill(1, &[0xff]),
);
}
#[test]
fn absent_duration_interval_and_clock_are_accepted_as_wire_defaults() {
// The whole reason the decoder reports presence: an omitted tag and one
// explicitly encoded as zero share a value but not a verdict.
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(
peer(),
&open_request_with_raw_params(&param_int(1, 1), None),
)
.unwrap()
.expect("absent duration, interval and clock are all valid");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.params.duration_ns, 0);
assert_eq!(reply.params.interval_ns, 0);
assert_eq!(reply.params.clock, Clock::Unspecified);
assert_eq!(core.session_count(), 1);
}
#[test]
fn valid_optional_parameter_values_survive_unchanged() {
for (name, requested) in [
(
"clock wall",
Params {
clock: Clock::Wall,
..Params::default()
},
),
(
"clock monotonic",
Params {
clock: Clock::Monotonic,
..Params::default()
},
),
(
"clock both",
Params {
clock: Clock::Both,
..Params::default()
},
),
(
"received stats and stamp at",
Params {
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Midpoint,
clock: Clock::Wall,
..Params::default()
},
),
(
"positive duration and interval",
Params {
duration_ns: 1,
interval_ns: 1,
..Params::default()
},
),
(
"maximum length server fill",
Params {
server_fill: Some(ServerFill {
value: "0123456789abcdef0123456789abcdef".to_owned(),
}),
..Params::default()
},
),
] {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.unwrap_or_else(|| panic!("{name} must be answered"));
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..requested
},
"{name} must survive negotiation unchanged"
);
}
}
+96
View File
@@ -0,0 +1,96 @@
use super::support::{
client_params, core_with_tokens, expect_normal_open_reply, open_request, other_peer, peer,
ScriptedTokens,
};
use crate::{ServerConfig, DEFAULT_MAX_SESSIONS};
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
const TOKEN_C: u64 = 0x2122_2324_2526_2728;
#[test]
fn the_default_session_bound_is_finite() {
let bound = ServerConfig::default().max_sessions();
assert_eq!(bound, DEFAULT_MAX_SESSIONS);
assert!(bound > 0, "the default bound must admit sessions");
assert!(
bound < usize::MAX,
"there is deliberately no unlimited default"
);
}
#[test]
fn repeated_opens_from_one_endpoint_create_independent_sessions() {
// Opens are never deduplicated by endpoint or by parameters: a client
// retransmission legitimately produces a second, independent session.
let mut core = core_with_tokens(
ServerConfig::default(),
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
);
let request = open_request(&client_params(), None);
let first = core
.handle_datagram(peer(), &request)
.unwrap()
.expect("first open must be answered");
let second = core
.handle_datagram(peer(), &request)
.unwrap()
.expect("second open must be answered");
let first = expect_normal_open_reply(&first, None);
let second = expect_normal_open_reply(&second, None);
assert_ne!(first.token, 0);
assert_ne!(second.token, 0);
assert_ne!(first.token, second.token);
assert_eq!(core.session_count(), 2);
assert_eq!(core.session(first.token).unwrap().peer(), peer());
assert_eq!(core.session(second.token).unwrap().peer(), peer());
}
#[test]
fn a_full_session_table_drops_new_opens_without_evicting_anything() {
let tokens = ScriptedTokens::new([TOKEN_A, TOKEN_B, TOKEN_C]);
let mut core = core_with_tokens(ServerConfig::default().with_max_sessions(2), tokens.clone());
let request = open_request(&client_params(), None);
core.handle_datagram(peer(), &request)
.unwrap()
.expect("first open must be answered");
core.handle_datagram(other_peer(), &request)
.unwrap()
.expect("second open must be answered");
assert_eq!(core.session_count(), 2);
assert_eq!(core.session(TOKEN_A).unwrap().peer(), peer());
assert_eq!(core.session(TOKEN_B).unwrap().peer(), other_peer());
// Refusal is silence: the protocol has no rejection reply, so the client
// simply sees an open timeout.
assert_eq!(core.handle_datagram(peer(), &request).unwrap(), None);
assert_eq!(core.session_count(), 2, "nothing may be evicted");
assert_eq!(core.session(TOKEN_A).unwrap().peer(), peer());
assert_eq!(core.session(TOKEN_B).unwrap().peer(), other_peer());
assert_eq!(core.session(TOKEN_C), None);
assert_eq!(
tokens.remaining(),
1,
"a refused open must not commit a token"
);
}
#[test]
fn a_zero_session_bound_refuses_every_session_creating_open() {
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(ServerConfig::default().with_max_sessions(0), tokens.clone());
assert_eq!(
core.handle_datagram(peer(), &open_request(&client_params(), None))
.unwrap(),
None
);
assert_eq!(core.session_count(), 0);
assert_eq!(tokens.remaining(), 1);
}
+179
View File
@@ -0,0 +1,179 @@
use std::{
collections::VecDeque,
net::SocketAddr,
sync::{Arc, Mutex},
};
use irtt_proto::{
decode_open_reply, encode_request, varint, OpenReply, PacketLayout, Params, RequestToEncode,
FLAG_CLOSE, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY,
};
use crate::{core::ServerCore, error::ServerError, token::TokenSource, ServerConfig};
pub(crate) const KEY: &[u8] = b"correctkey";
pub(crate) const OTHER_KEY: &[u8] = b"wrongkey";
pub(crate) fn peer() -> SocketAddr {
"198.51.100.7:41234".parse().unwrap()
}
pub(crate) fn other_peer() -> SocketAddr {
"198.51.100.7:41235".parse().unwrap()
}
/// A token source handing out a fixed script of values, then failing.
///
/// Draining the script is itself a failure rather than a wrap-around, so a test
/// that draws more tokens than it scripted is a loud test bug instead of a
/// silent repeat.
#[derive(Debug, Clone)]
pub(crate) struct ScriptedTokens {
values: Arc<Mutex<VecDeque<Result<u64, ServerError>>>>,
}
impl ScriptedTokens {
pub(crate) fn new<I>(values: I) -> Self
where
I: IntoIterator<Item = u64>,
{
Self {
values: Arc::new(Mutex::new(values.into_iter().map(Ok).collect())),
}
}
/// A source whose every draw reports a random-source failure.
pub(crate) fn failing() -> Self {
Self {
values: Arc::new(Mutex::new(VecDeque::new())),
}
}
/// How many scripted values are still unused.
pub(crate) fn remaining(&self) -> usize {
self.values.lock().unwrap().len()
}
}
impl TokenSource for ScriptedTokens {
fn next_token(&mut self) -> Result<u64, ServerError> {
self.values
.lock()
.unwrap()
.pop_front()
.unwrap_or(Err(ServerError::RandomSource {
reason: "scripted failure".to_owned(),
}))
}
}
/// A core with deterministic tokens, so session identity is assertable.
pub(crate) fn core_with_tokens(config: ServerConfig, tokens: ScriptedTokens) -> ServerCore {
ServerCore::with_token_source(config, Box::new(tokens))
}
/// Encodes a normal open request carrying `params`.
pub(crate) fn open_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8> {
encode_request(
RequestToEncode::Open {
params,
no_test: false,
},
hmac_key,
)
.unwrap()
}
/// Encodes a no-test open request carrying `params`.
pub(crate) fn no_test_request(params: &Params, hmac_key: Option<&[u8]>) -> Vec<u8> {
encode_request(
RequestToEncode::Open {
params,
no_test: true,
},
hmac_key,
)
.unwrap()
}
/// Builds an open request around a raw parameter payload.
///
/// Used for payloads a compliant encoder cannot produce, such as a truncated
/// varint or an out-of-range enum value.
pub(crate) fn open_request_with_raw_params(payload: &[u8], hmac_key: Option<&[u8]>) -> Vec<u8> {
let mut packet = irtt_proto::MAGIC.to_vec();
packet.push(if hmac_key.is_some() {
FLAG_OPEN | FLAG_HMAC
} else {
FLAG_OPEN
});
if hmac_key.is_some() {
packet.extend_from_slice(&[0; irtt_proto::HMAC_SIZE]);
}
packet.extend_from_slice(payload);
if let Some(key) = hmac_key {
irtt_proto::compute_hmac_in_place(key, &mut packet, hmac_field_offset()).unwrap();
}
packet
}
/// The authentication field sits immediately after the fixed header.
fn hmac_field_offset() -> usize {
PacketLayout::open_request(false).header_len()
}
/// Encodes one integer parameter as `tag` + zigzag value.
pub(crate) fn param_int(tag: u64, value: i64) -> Vec<u8> {
let mut encoded = Vec::new();
varint::encode_uvarint(tag, &mut encoded);
varint::encode_varint(value, &mut encoded);
encoded
}
/// Encodes a `server_fill` parameter with an explicit declared length, so tests
/// can declare a length the payload does not actually carry.
pub(crate) fn param_server_fill(declared_len: u64, value: &[u8]) -> Vec<u8> {
let mut encoded = Vec::new();
varint::encode_uvarint(9, &mut encoded);
varint::encode_uvarint(declared_len, &mut encoded);
encoded.extend_from_slice(value);
encoded
}
/// Params a well-behaved client would request.
pub(crate) fn client_params() -> Params {
Params {
protocol_version: 1,
duration_ns: 3_000_000_000,
interval_ns: 1_000_000_000,
length: 1472,
received_stats: irtt_proto::ReceivedStats::Both,
stamp_at: irtt_proto::StampAt::Both,
clock: irtt_proto::Clock::Both,
dscp: 184,
server_fill: None,
}
}
/// Decodes a reply the server produced, checking it against the server's key.
pub(crate) fn decode_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> OpenReply {
decode_open_reply(packet, hmac_key).expect("server reply must decode")
}
pub(crate) fn expect_normal_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> OpenReply {
let reply = decode_reply(packet, hmac_key);
let expected = FLAG_OPEN | FLAG_REPLY | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
assert_eq!(reply.flags, expected, "normal open reply flags");
assert_ne!(reply.token, 0, "a session-creating reply needs a token");
assert_eq!(reply.params.protocol_version, 1);
reply
}
pub(crate) fn expect_no_test_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> OpenReply {
let reply = decode_reply(packet, hmac_key);
let expected =
FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE | if hmac_key.is_some() { FLAG_HMAC } else { 0 };
assert_eq!(reply.flags, expected, "no-test reply flags");
assert_eq!(reply.token, 0, "a no-test reply carries a zero token");
reply
}
+86
View File
@@ -0,0 +1,86 @@
use super::support::{
client_params, core_with_tokens, expect_normal_open_reply, open_request, peer, ScriptedTokens,
};
use crate::{token::TOKEN_ATTEMPTS, ServerConfig, ServerError};
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
#[test]
fn a_zero_draw_is_discarded_and_redrawn() {
// Zero is reserved for no-test replies and is never issued to a session.
let mut core = core_with_tokens(
ServerConfig::default(),
ScriptedTokens::new([0, 0, TOKEN_A]),
);
let packet = core
.handle_datagram(peer(), &open_request(&client_params(), None))
.unwrap()
.expect("a zero draw must be retried, not surfaced");
assert_eq!(expect_normal_open_reply(&packet, None).token, TOKEN_A);
assert_eq!(core.session_count(), 1);
}
#[test]
fn a_colliding_draw_is_discarded_and_never_overwrites_the_live_session() {
let mut core = core_with_tokens(
ServerConfig::default(),
ScriptedTokens::new([TOKEN_A, TOKEN_A, TOKEN_B]),
);
let request = open_request(&client_params(), None);
core.handle_datagram(peer(), &request)
.unwrap()
.expect("first open must be answered");
let first = core.session(TOKEN_A).cloned().expect("session must exist");
let packet = core
.handle_datagram(peer(), &request)
.unwrap()
.expect("a collision must be retried, not surfaced");
assert_eq!(expect_normal_open_reply(&packet, None).token, TOKEN_B);
assert_eq!(core.session_count(), 2);
assert_eq!(
core.session(TOKEN_A),
Some(&first),
"the colliding token's session must be untouched"
);
}
#[test]
fn exhausting_the_retry_budget_is_an_internal_error_that_creates_nothing() {
let mut core = core_with_tokens(
ServerConfig::default(),
ScriptedTokens::new([TOKEN_A, 0, 0, 0, 0, 0, 0, 0, 0]),
);
let request = open_request(&client_params(), None);
core.handle_datagram(peer(), &request)
.unwrap()
.expect("first open must be answered");
let first = core.session(TOKEN_A).cloned().expect("session must exist");
assert_eq!(
core.handle_datagram(peer(), &request),
Err(ServerError::TokenExhausted {
attempts: TOKEN_ATTEMPTS
})
);
assert_eq!(core.session_count(), 1, "no session may be half-created");
assert_eq!(core.session(TOKEN_A), Some(&first));
}
#[test]
fn a_failing_random_source_is_reported_and_creates_nothing() {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::failing());
assert!(matches!(
core.handle_datagram(peer(), &open_request(&client_params(), None)),
Err(ServerError::RandomSource { .. })
));
assert_eq!(core.session_count(), 0);
}
@@ -0,0 +1,93 @@
//! Requests this slice deliberately does not implement.
//!
//! Echo and close are recognized by the admission boundary and then ignored.
//! These tests exist to keep the scope boundary honest: a half-implemented
//! lifecycle must not appear by accident before the slices that own it.
use irtt_proto::{encode_request, RequestToEncode};
use super::support::{client_params, core_with_tokens, open_request, peer, ScriptedTokens, KEY};
use crate::ServerConfig;
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
fn open_one_session(hmac_key: Option<&[u8]>) -> crate::ServerCore {
let mut config = ServerConfig::default();
if let Some(key) = hmac_key {
config = config.with_hmac_key(key);
}
let mut core = core_with_tokens(config, ScriptedTokens::new([TOKEN_A]));
core.handle_datagram(peer(), &open_request(&client_params(), hmac_key))
.unwrap()
.expect("the session-creating open must succeed");
core
}
#[test]
fn a_valid_echo_is_neither_answered_nor_counted_yet() {
for hmac_key in [None, Some(KEY)] {
let mut core = open_one_session(hmac_key);
let session = core.session(TOKEN_A).cloned().expect("session must exist");
let echo = encode_request(
RequestToEncode::Echo {
token: TOKEN_A,
sequence: 0,
params: session.params(),
payload: &[],
},
hmac_key,
)
.unwrap();
assert_eq!(core.handle_datagram(peer(), &echo).unwrap(), None);
assert_eq!(core.session_count(), 1);
assert_eq!(core.session(TOKEN_A), Some(&session));
}
}
#[test]
fn a_valid_close_does_not_remove_the_session_yet() {
for hmac_key in [None, Some(KEY)] {
let mut core = open_one_session(hmac_key);
let session = core.session(TOKEN_A).cloned().expect("session must exist");
let close = encode_request(RequestToEncode::Close { token: TOKEN_A }, hmac_key).unwrap();
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
assert_eq!(core.session_count(), 1);
assert_eq!(core.session(TOKEN_A), Some(&session));
}
}
#[test]
fn structurally_invalid_datagrams_are_discarded() {
let mut core = open_one_session(None);
let session = core.session(TOKEN_A).cloned().expect("session must exist");
let mut bad_magic = open_request(&client_params(), None);
bad_magic[0] ^= 0x01;
let mut reserved_flag = open_request(&client_params(), None);
reserved_flag[3] |= 0x10;
let mut reply_flag = open_request(&client_params(), None);
reply_flag[3] |= irtt_proto::FLAG_REPLY;
for (name, packet) in [
("empty datagram", Vec::new()),
("datagram shorter than the header", vec![0x14, 0xa7, 0x5b]),
("bad magic", bad_magic),
("reserved flag bit", reserved_flag),
("reply flag", reply_flag),
] {
assert_eq!(
core.handle_datagram(peer(), &packet).unwrap(),
None,
"{name} must be discarded"
);
}
assert_eq!(core.session_count(), 1);
assert_eq!(core.session(TOKEN_A), Some(&session));
}
+43
View File
@@ -0,0 +1,43 @@
//! Session token generation.
//!
//! A token is the only thing standing between an off-path attacker and a usable
//! session on an unauthenticated server, so production tokens come from the
//! operating system's cryptographically secure random source. Nothing here
//! derives a token from a counter, a timestamp, a hash of the source address,
//! or any other predictable value, and there is no fallback that would do so if
//! the random source fails.
use std::fmt;
use crate::error::ServerError;
/// How many values token allocation may draw before giving up.
///
/// Allocation rejects a drawn value that is zero (reserved for no-test replies)
/// or that collides with a live session, and draws again. With a working random
/// source neither outcome is realistically reachable even once; the bound
/// exists so a degenerate source cannot spin the allocator forever.
pub(crate) const TOKEN_ATTEMPTS: u32 = 8;
/// A source of candidate session tokens.
///
/// Deliberately private: it exists so tests can drive the zero, collision and
/// failure paths deterministically, not as a public extension point. The
/// production implementation is [`OsTokenSource`].
pub(crate) trait TokenSource: fmt::Debug + Send {
/// Draws the next candidate token. The value is *not* required to be
/// non-zero or unique; that is the allocator's job.
fn next_token(&mut self) -> Result<u64, ServerError>;
}
/// Draws tokens from the operating system's random source.
#[derive(Debug, Default)]
pub(crate) struct OsTokenSource;
impl TokenSource for OsTokenSource {
fn next_token(&mut self) -> Result<u64, ServerError> {
getrandom::u64().map_err(|source| ServerError::RandomSource {
reason: source.to_string(),
})
}
}