feat(server): add open session core
Add `irtt-server` with the deterministic protocol and session engine the upcoming Tokio runtime will call. `ServerCore::handle_datagram` takes a source endpoint and the bytes of one datagram and returns the bytes to send back, or nothing. It performs no I/O and keeps no clock, so every decision is a pure function of its state and that input apart from drawing session tokens. Rejection is silence. The protocol defines no error reply, no reset and no NACK, so malformed, unauthenticated and policy-refused datagrams are discarded without a response and without disturbing any live session; a client tells "rejected" from "lost" only by timing out. `ServerError` is reserved for internal failures on the server's own side, so an inbound `ProtoError` never surfaces as a server failure. Open handling authenticates before decoding parameters, so a packet that fails authentication is discarded without parsing untrusted data and no reply behavior reveals which stage rejected it. Duration and Interval are accepted when absent and rejected when explicitly present and not positive, which is what the presence-aware parameter decode is for. Negotiation currently rewrites only the protocol version; the restriction knobs the specification describes are deliberately left for their own slices, with one obvious place to add them. Sessions are bounded to a finite default. A single unauthenticated datagram creates one and opens are never deduplicated, so an unbounded table is a liability rather than a compatibility feature; upstream's absence of session bounds is explicitly not a target. A full table drops new opens silently and evicts nothing. No-test opens keep working at capacity because they create no state. Session tokens come from the operating system's random source, since a token is the only thing standing between an off-path attacker and a usable session on an unauthenticated server. A drawn value that is zero or collides with a live session is redrawn within a finite budget; there is no predictable fallback, and a colliding token never overwrites the session that holds it. The reply is encoded before the session is inserted, so an internal failure cannot leave a half-created session behind. Echo and close requests pass admission and are then deliberately ignored: this slice creates sessions, and lifecycle and receive semantics belong to the slices that implement them. Tests pin that so a half-implemented lifecycle cannot appear by accident.
This commit is contained in:
21 files changed
+1744
-4
No files matched your search
@@ -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