feat(server): enforce rate and session lifetime

This commit is contained in:
Nils Andreas Svee committed 2026-08-13 00:45:40 +07:00
1 parent 683b52d334
commit c2994f64a4
10 files changed
+1506 -70

No files matched your search

+131 -23
View File
@@ -7,11 +7,11 @@ use irtt_proto::{
};
use crate::{
clock::{ClockSample, ClockSource, SystemClock},
clock::{saturating_ns, ClockSample, ClockSource, SystemClock},
config::ServerConfig,
error::ServerError,
negotiate::negotiate_params,
session::Session,
session::{RateDecision, Session},
token::{OsTokenSource, TokenSource, TOKEN_ATTEMPTS},
};
@@ -39,11 +39,12 @@ use crate::{
///
/// # Scope
///
/// This slice implements open handling, session creation, normal echo
/// processing and client-initiated close. Rate limiting, session lifetime and
/// expiry, server-initiated close, DSCP application and the server fill policy
/// are separate slices, so every otherwise admissible echo is answered and no
/// session ever ages out.
/// This slice implements open handling, session creation and negotiation,
/// normal echo processing, per-session rate limiting, idle expiry,
/// maximum-duration close and client-initiated close. DSCP application and the
/// full server fill policy are separate slices, as is the Tokio runtime — so
/// expiry is evaluated when a datagram arrives rather than on a timer, and
/// echo payloads are zero-filled.
#[derive(Debug)]
pub struct ServerCore {
config: ServerConfig,
@@ -122,7 +123,14 @@ impl ServerCore {
///
/// `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.
/// version 1 defines no acknowledgement for one — and so is an echo that
/// found its session's rate allowance spent, which advances no statistic
/// but does keep the session alive.
///
/// Sessions whose idle deadline has passed are released before the request
/// is dispatched, so a request may find the session it names already gone,
/// and one request may release an unrelated session. Expiry is never
/// signalled; a client sees only silence.
///
/// Authentication is checked before open parameters are decoded and before
/// any session is looked up. That ordering is deliberate: a packet that
@@ -165,8 +173,23 @@ impl ServerCore {
}
}
// One monotonic instant per authenticated, structurally valid request,
// which is the request's whole lifecycle time: what expiry is judged
// against, what a new session's deadlines start from, and what the rate
// limiter and maximum-duration deadline read. An echo already has one —
// the instant its reply will report as the receive time — and taking a
// second would let a reply's timestamps disagree with the policy
// decisions made about the very same datagram.
let now_ns = match kind {
ClassifiedKind::Echo(ref echo) => echo.received_at.mono_ns,
_ => self.clock.sample().mono_ns,
};
self.release_expired_sessions(now_ns);
match kind {
ClassifiedKind::Open { no_test, params } => self.handle_open(peer, no_test, params),
ClassifiedKind::Open { no_test, params } => {
self.handle_open(peer, no_test, params, now_ns)
}
ClassifiedKind::Close { token } => {
self.handle_close(peer, token);
Ok(None)
@@ -175,6 +198,32 @@ impl ServerCore {
}
}
/// Drops every session whose idle deadline has passed, as of `now_ns`.
///
/// Expiry is *logical* — a session is expired the instant its deadline
/// passes — but reclamation here is *packet-driven*, because this slice has
/// no runtime and so no timer. An expired session may therefore stay
/// resident until the next authenticated, structurally valid request causes
/// this sweep. Nothing observable turns on the difference: total state is
/// already bounded by [`max_sessions`](ServerConfig::max_sessions), the
/// sweep runs before a new open is admitted so a stale session cannot deny
/// capacity to a live one, and while no traffic arrives at all there is
/// no peer to observe anything. The Tokio slice may add scheduled sweeps.
///
/// The sweep is global and runs before the request is dispatched, so a
/// request touching one session can release another. That is deliberate:
/// expiry is a resource decision about the table, not a side effect of what
/// the current datagram happens to name. It also means a *no-test* open —
/// which creates nothing of its own — reclaims expired sessions like any
/// other open, where the reference server was observed not to. Both are
/// upstream policy the clean specification declines to require, and a
/// client cannot tell the difference: expiry is never signalled.
fn release_expired_sessions(&mut self, now_ns: i64) {
let idle_timeout_ns = saturating_ns(self.config.idle_timeout());
self.sessions
.retain(|_, session| !session.is_idle_expired(now_ns, idle_timeout_ns));
}
/// Pairs a structurally decoded request with the clock sample an echo
/// needs, leaving every other kind untouched.
fn classify<'a>(
@@ -206,9 +255,10 @@ impl ServerCore {
/// discard: the datagram must not be longer than
/// [`max_packet_length`](ServerConfig::max_packet_length), its token must
/// name a live session, and it must have arrived from that session's own
/// endpoint. Authentication was already settled by the caller. A request
/// failing any of these leaves the receive state — of this session and of
/// every other — exactly where it was.
/// endpoint. Authentication was already settled by the caller, as was idle
/// expiry — a request reaching here found its session still alive. A
/// request failing any of these leaves the receive state — of this session
/// and of every other — exactly where it was.
///
/// Only the token and the sequence number mean anything. The request's own
/// length is judged against the configured maximum and then forgotten: it
@@ -216,9 +266,24 @@ impl ServerCore {
/// reply, which is laid out and sized from the session's negotiated
/// parameters alone.
///
/// The next receive state is computed but not committed until the reply has
/// **Rate allowance is judged after admission and before the
/// maximum-duration deadline.** That order is the observed one and it
/// matters: a post-deadline echo with no allowance is simply dropped, and
/// the close it would have carried is not lost but handed to the next echo
/// that is served. A rate-limited request refreshes the session's idle
/// deadline — the one drop class the clean evidence records doing so — and
/// changes nothing else.
///
/// **The maximum-duration close is an ordinary reply with the Close flag
/// added**, never a standalone packet: protocol version 1 has no other
/// in-band mechanism. It reports the triggering request in its statistics
/// exactly as any reply would, and the session is released once it has
/// encoded, so every later request is dropped as an unknown token.
///
/// Every state transition is computed but not committed until the reply has
/// been encoded, so a reply this server failed to build cannot leave a
/// session claiming to have answered a request it never did.
/// session claiming to have answered a request it never did — or, for a
/// close, leave the session released with no reply carrying the flag.
fn handle_echo(
&mut self,
peer: SocketAddr,
@@ -239,7 +304,29 @@ impl ServerCore {
return Ok(None);
}
let now_ns = request.received_at.mono_ns;
let next_rate = match session.rate_state().charge(now_ns) {
RateDecision::Limited { next } => {
// Silence, and no statistic moves — but the session lives on,
// and its idle deadline moves with it.
let next_lifetime = session.lifetime_state().refreshed(now_ns);
session.commit_rate_state(next);
session.commit_lifetime_state(next_lifetime);
return Ok(None);
}
RateDecision::Allowed { next } => next,
};
// Only a session that has already served an echo has a deadline at all,
// so this can never fire on the request that starts the test.
let closing = self.config.max_test_duration().is_some_and(|maximum| {
session
.lifetime_state()
.max_duration_reached(now_ns, saturating_ns(maximum))
});
let next = session.receive_state().accepted(request.sequence);
let next_lifetime = session.lifetime_state().served(now_ns);
// The departure side, sampled as late as the core can: after admission
// and the transition, immediately before the reply is built. There is
// no socket here, so this is the core's departure instant rather than a
@@ -248,11 +335,12 @@ impl ServerCore {
let sent_at = self.clock.sample();
let params = session.params();
let reply = EchoReply {
// Reply only. Open must be clear or upstream clients abort, and
// Close belongs to the server-initiated close slice. The
// authentication flag and field are the encoder's, decided by the
// key it is given.
flags: FLAG_REPLY,
// Open must be clear or upstream clients abort. Close is set only
// for the maximum-duration limit, which is the sole trigger this
// server has. The authentication flag and field are the encoder's,
// decided by the key it is given — a close-flagged reply is
// authenticated by exactly the same path as any other.
flags: FLAG_REPLY | if closing { FLAG_CLOSE } else { 0 },
// Both are copied through unchanged: no token is allocated for an
// echo, and no sequence number is normalized or range-checked.
token: request.token,
@@ -270,16 +358,34 @@ impl ServerCore {
let packet = encode_echo_reply(&reply, params, self.config.hmac_key())
.map_err(|source| ServerError::ReplyEncoding { source })?;
// The last fallible step is behind us, so the transition can be made.
session.commit_receive_state(next);
// The last fallible step is behind us, so the transitions can be made.
if closing {
// The reply already carries the state this request produced, so
// there is nothing worth committing into a session about to go. A
// failure above would have left it live and untouched instead.
self.sessions.remove(&request.token);
} else {
session.commit_rate_state(next_rate);
session.commit_receive_state(next);
session.commit_lifetime_state(next_lifetime);
}
Ok(Some(packet))
}
/// Negotiates an open request and, unless it is a no-test one, creates the
/// session it asks for.
///
/// `now_ns` is the request's lifecycle instant, which becomes the new
/// session's idle origin: a session starts aging the moment it exists, not
/// when it first carries an echo, so one whose client never returns is
/// still reclaimed. Expired sessions were already swept, so the capacity
/// bound below is judged against sessions that are genuinely live.
fn handle_open(
&mut self,
peer: SocketAddr,
no_test: bool,
encoded_params: &[u8],
now_ns: i64,
) -> 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
@@ -325,8 +431,10 @@ impl ServerCore {
// 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));
self.sessions.insert(
token,
Session::new(peer, reply.params, &self.config, now_ns),
);
Ok(Some(packet))
}