Files
irtt-rs/crates/irtt-server/src/lib.rs
T
Nils Andreas Svee 09f0d6e02e feat(server): add multi-listener server set
`Server` answers on one address. A host that must serve several — an
explicit IPv4 and IPv6 pair, or two interface addresses — needs several
of them, and something has to own their construction, their shutdown and
each other's failures.

`ServerSet` is that owner, and deliberately not a protocol object: it
holds no session state, no tokens and no clock, and nothing it does is
visible on the wire. Each listener stays a complete `Server` with its own
socket, core, session table and tokens, so a token issued by one is
unknown at the others and every bound in the cloned `ServerConfig` —
`max_sessions` included — applies per listener rather than per process.

Construction binds every listener or none, preserving requested order and
recording the addresses actually bound. `run` moves each listener into
its own task, fans one shutdown future out over a watch channel, and
joins them all: a listener that fails, or that returns before shutdown
was asked for, shuts its siblings down and fails the set, because a
service configured for two families must not continue silently as one.

A genuine IPv6 listener sharing an explicit port with an IPv4 sibling is
created `IPV6_V6ONLY` before it is bound, since a dual-stack socket would
otherwise claim the IPv4 port too. That is the only pre-bind step, it
uses safe `socket2` APIs, and an IPv4-mapped address is read as the IPv4
listener it is — never as the IPv6 half of a pair. Standalone
`Server::bind` semantics are unchanged.
2026-08-14 16:04:46 +07:00

133 lines
6.2 KiB
Rust

//! 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, echo
//! receive state, rate and lifetime policy, resource decisions and reply
//! construction. It performs no I/O; the clock its timestamps and deadlines
//! come from is a private injected seam, not a runtime abstraction, so the
//! engine stays testable. Each reply it produces is an [`OutboundDatagram`]:
//! the packet and the raw traffic class it must be sent with. [`Server`] owns
//! one Tokio UDP listener and one core, and provides sequential receive, reply,
//! scheduled expiry maintenance and caller-controlled shutdown. [`ServerSet`]
//! owns one or more `Server`s as a single service: it binds them all or none,
//! runs each in its own task, fans one shutdown out to them and fails the group
//! if any listener does. The crate is intentionally Tokio-native; there is no
//! blocking or alternate-runtime counterpart and no transport abstraction.
//!
//! # One listener or several
//!
//! `Server` is the primitive and stays directly usable. `ServerSet` is the
//! service around it, and a set of one is an ordinary set rather than a special
//! case — the server CLI runs every invocation, single-bind included, through
//! one.
//!
//! Listeners in a set share nothing but the configuration they were cloned
//! from. Each has its own socket, core, session table and tokens, so a token
//! issued by one is unknown at the others, and every bound in
//! [`ServerConfig`] — `max_sessions` included — applies **per listener**, not
//! per process.
//!
//! # 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`]), and so is the
//! echo datagram a single session can negotiate (see
//! [`DEFAULT_MAX_PACKET_LENGTH`]), the rate one session is answered at (see
//! [`DEFAULT_MIN_SEND_INTERVAL`] and [`DEFAULT_BURST_ALLOWANCE`]) and how long
//! one lives without traffic (see [`DEFAULT_IDLE_TIMEOUT`]). A single
//! unauthenticated datagram creates a session and opens are never deduplicated,
//! so neither an unbounded table nor a remotely chosen buffer size is a
//! compatibility feature. Upstream's observed lack of session bounds, its
//! unlimited default packet length, and its never expiring a session that has
//! not carried an echo request are explicitly not compatibility targets: an
//! `irtt-rs` session ages from the moment it is opened.
//!
//! # Reply source address
//!
//! A reply leaves from the address its request was sent to. An
//! explicit-address listener gets that from the bind and uses ordinary
//! receive/send. A wildcard listener (`0.0.0.0` or `[::]`) instead reads each
//! request's local destination from packet metadata and sends that request's
//! reply from it, so a client on the host's second address is answered from the
//! endpoint it contacted rather than from whichever one the routing table would
//! have chosen.
//!
//! That path is implemented for Linux, macOS and FreeBSD. On other targets a
//! wildcard bind is refused by [`Server::from_socket`] — and so by
//! [`Server::bind`] — rather than served incorrectly; explicit-address
//! listeners work everywhere they did before.
//!
//! # Current scope
//!
//! Open handling and negotiation, session creation, normal echo processing,
//! per-session rate limiting, idle expiry, the maximum-duration
//! server-initiated close, the negotiated per-session reply traffic class,
//! server fill, the optional timestamp-allowance and DSCP capability
//! restrictions, wildcard reply-source selection, Tokio UDP orchestration and
//! multi-listener supervision through [`ServerSet`].
//!
//! # Capability restrictions
//!
//! Two optional negotiation policies restrict what a session may ask this server
//! to provide, and both are off by default, so a configuration that sets neither
//! negotiates exactly what it always did.
//!
//! [`TimestampAllowance`] reduces the requested timestamp placement: `Single`
//! provides at most one timestamp instant, answering a request for both instants
//! with the midpoint, and `None` provides no timestamps at all. The requested
//! clock is never rewritten, so a single placement still reports one field per
//! negotiated clock domain. [`ServerConfig::with_dscp_allowed`] refuses to provide
//! traffic-class marking, negotiating any requested DSCP value to zero, after
//! which the session's replies leave unmarked like any other zero-DSCP session.
//!
//! # Server fill
//!
//! An echo reply's payload region is filled from the session's negotiated
//! ServerFill descriptor: `none`, `rand`, or `pattern:` followed by a repeating
//! hexadecimal pattern. Every valid descriptor is accepted — there is no
//! allow-list to configure — and one this server cannot parse is answered with
//! its default, `pattern:69727474`, which is also what a client expressing no
//! preference is served without any change to the negotiated parameters.
//!
//! A `none` payload is zeroes. Payload bytes carry no protocol meaning, and a
//! server must never emit residue from another request or client. Request
//! payload bytes never reach a reply under any mode.
#![forbid(unsafe_code)]
mod clock;
mod config;
mod core;
mod error;
mod fill;
mod negotiate;
mod runtime;
mod session;
mod set;
mod socket_io;
mod socket_options;
mod token;
#[cfg(test)]
mod tests;
pub use config::{
ServerConfig, TimestampAllowance, DEFAULT_BURST_ALLOWANCE, DEFAULT_IDLE_TIMEOUT,
DEFAULT_MAX_PACKET_LENGTH, DEFAULT_MAX_SESSIONS, DEFAULT_MIN_SEND_INTERVAL,
};
pub use core::{OutboundDatagram, ServerCore};
pub use error::ServerError;
pub use runtime::{Server, ServerRuntimeError};
pub use set::{ServerSet, ServerSetError};