feat(server): add open session core #74
No files matched your search
@@ -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
@@ -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,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]
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(¶m_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);
|
||||
}
|
||||
@@ -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(¶m_int(1, 0), None),
|
||||
Some(_) => open_request(¶ms, 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(¶m_int(42, 7));
|
||||
payload.extend_from_slice(¶m_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);
|
||||
}
|
||||
}
|
||||
@@ -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(¶m_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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user