Files
irtt-rs/crates/irtt-server/src/core.rs
T
Nils Andreas Svee 6c34fd3d20 docs(server): record IPv6 zone identity as project policy
Endpoint identity documented the IPv6 scope identifier as an established
component alongside family, address and port. The server specification does
list the zone, but its remaining-unknowns section records that multi-zone
behavior could not be tested on a single-host platform, so no evidence settles
how a reference server compares scoped addresses. Presenting the choice as
verified compatibility overstated what is known, which the crate's own guidance
forbids.

Say what it is instead: a deliberate policy taken under a recorded unknown. Two
peers reachable at the same link-local address in different zones are genuinely
different peers, so treating the zone as identity is the conservative reading —
the failure it prevents is one peer closing another's session, and the failure
it risks is a scoped client having to reopen. The excluded flow label is
labelled the same way, and `irtt-server` guidance now carries the rule so the
echo slice reuses this comparison rather than inventing a second one.

Behavior is unchanged; this is documentation and a test comment.
2026-08-12 10:48:42 +07:00

304 lines
13 KiB
Rust

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, session creation and client-initiated
/// close. Echo requests are structurally recognized and then deliberately
/// ignored — no reply, no session mutation — until their own slice.
#[derive(Debug)]
pub struct ServerCore {
config: ServerConfig,
sessions: HashMap<u64, Session>,
tokens: Box<dyn TokenSource>,
}
impl ServerCore {
/// Creates a server core drawing session tokens from the operating system's
/// random source.
#[must_use]
pub fn new(config: ServerConfig) -> Self {
Self::with_token_source(config, Box::new(OsTokenSource))
}
pub(crate) fn with_token_source(config: ServerConfig, tokens: Box<dyn TokenSource>) -> Self {
Self {
config,
sessions: HashMap::new(),
tokens,
}
}
/// This core's configuration.
#[must_use]
pub fn config(&self) -> &ServerConfig {
&self.config
}
/// The number of live sessions.
///
/// This is the observable a caller genuinely needs — how much session state
/// the server is holding against its bound. The sessions themselves stay
/// crate-private until their shape settles.
#[must_use]
pub fn session_count(&self) -> usize {
self.sessions.len()
}
/// Looks up a live session by token.
///
/// This is a lookup by token alone and is therefore *not* an admission
/// check: a request is bound to a session only when the token matches and
/// it arrived from the session's exact endpoint. Callers pair it with an
/// endpoint comparison, as close handling does.
pub(crate) fn session(&self, token: u64) -> Option<&Session> {
self.sessions.get(&token)
}
/// Handles one received datagram, returning the reply to send, if any.
///
/// `Ok(None)` means "answer nothing". The protocol defines no error reply,
/// no reset and no NACK, so every rejection is a silent discard and a
/// client distinguishes "rejected" from "lost" only by timing out. A
/// datagram is discarded when it is structurally invalid, carries the reply
/// flag, disagrees with this server's authentication configuration, fails
/// authentication, carries a malformed or out-of-range open parameter
/// payload, would exceed the session bound, or is a request kind this slice
/// does not implement. None of that mutates any live session.
///
/// `Ok(None)` is not only a rejection, though. A close request that does
/// release its session is answered with nothing as well, because protocol
/// version 1 defines no acknowledgement for one.
///
/// 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),
DecodedRequestKind::Close { token } => {
self.handle_close(peer, token);
Ok(None)
}
// Not implemented in this slice. An echo must not advance receive
// state until the slice that implements it; emitting a placeholder
// reply now would be worse than silence.
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))
}
/// Releases the session a close request names, if that request owns one.
///
/// A close is bound to a session only when the token matches *and* the
/// datagram arrived from that session's exact endpoint, so holding a token
/// is not by itself authority to tear the session down: a close from a
/// foreign endpoint leaves it live, and one close cannot take down a
/// sibling session that the same peer opened separately. A token that is
/// unknown, already closed, or zero — zero is reserved for no-test replies
/// and never issued — matches nothing and leaves the table untouched.
///
/// Nothing is sent either way, which is why this returns no reply to
/// encode. Protocol version 1 defines no acknowledgement for a client
/// close, so a delivered close is indistinguishable to the client from a
/// lost one, and the freed capacity is immediately reusable because the
/// session table is the only record of it.
fn handle_close(&mut self, peer: SocketAddr, token: u64) {
let owns_session = self
.session(token)
.is_some_and(|session| same_endpoint(session.peer(), peer));
if owns_session {
self.sessions.remove(&token);
}
}
/// 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,
})
}
}
/// Whether two source endpoints identify the same session endpoint.
///
/// The address family, the IP address and the UDP source port are established
/// identity components: a request from a different port, address or family is
/// dropped and leaves its session live.
///
/// The remaining two fields of [`SocketAddrV6`] are decided here rather than
/// observed, which is why this is not simply `==`.
///
/// The flow label is excluded. It identifies no endpoint — it is routing
/// metadata a sender or forwarding path may legitimately vary within one flow,
/// and a receiver reports it only when asked to — so admitting it could strand
/// a session its own client can then never close. It grants nothing in
/// exchange: the token, address, port and scope are what an off-path attacker
/// would have to guess.
///
/// The scope identifier is included, and that is **`irtt-rs` policy, not
/// verified compatibility behavior**. The server specification lists the IPv6
/// zone as an identity component, but its remaining-unknowns section records
/// that multi-zone behavior could not be tested on a single-host platform, so
/// no evidence settles how a reference server compares scoped addresses. Two
/// peers reachable at the same link-local address in different zones are
/// genuinely different peers, so treating the zone as identity is the
/// conservative reading: the failure it prevents is one peer closing another's
/// session, while the failure it risks is a scoped client having to reopen.
/// Revisit if evidence ever settles the question.
///
/// [`SocketAddrV6`]: std::net::SocketAddrV6
fn same_endpoint(session: SocketAddr, peer: SocketAddr) -> bool {
match (session, peer) {
(SocketAddr::V6(session), SocketAddr::V6(peer)) => {
session.ip() == peer.ip()
&& session.port() == peer.port()
&& session.scope_id() == peer.scope_id()
}
(session, peer) => session == peer,
}
}
/// 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)
}