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
21 changed files with 1744 additions and 4 deletions
Showing only changes of commit 59f6e88005 - Show all commits

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]
+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,
}
}
}
+229
View File
@@ -0,0 +1,229 @@
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.
#[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.
#[must_use]
pub 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,
},
}
+53
View File
@@ -0,0 +1,53 @@
//! 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;
pub use session::Session;
+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
}
+41
View File
@@ -0,0 +1,41 @@
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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub 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.
#[must_use]
pub fn peer(&self) -> SocketAddr {
self.peer
}
/// The negotiated parameters the server returned for this session and will
/// enforce for it.
#[must_use]
pub 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(),
})
}
}