feat(server): enforce rate and session lifetime
This commit is contained in:
10 files changed
+1506
-70
No files matched your search
@@ -1,28 +1,62 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use irtt_proto::Params;
|
||||
|
||||
use crate::{clock::saturating_ns, config::ServerConfig};
|
||||
|
||||
/// How long past its negotiated maximum test duration a session is served
|
||||
/// before the next echo it answers carries the Close flag.
|
||||
///
|
||||
/// This is `irtt-rs` policy, not a protocol constant, chosen to match the
|
||||
/// additive two-second margin the clean evidence measured of the reference
|
||||
/// server at maxima of 500 ms, 1 s and 3 s. It is deliberately not
|
||||
/// configurable: a conforming client stops at the negotiated duration and never
|
||||
/// reaches the deadline at all, so the margin exists only to be forgiving of
|
||||
/// clients that do not, and an operator has the maximum itself to tune.
|
||||
const MAX_DURATION_CLOSE_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// A live session created by an accepted open request.
|
||||
///
|
||||
/// A session records what identifies it, what was negotiated for it, and what
|
||||
/// its accepted echo requests have added up to. Lifetime state (activity,
|
||||
/// deadlines, expiry) and rate state belong to the slices that implement those
|
||||
/// behaviors, and are deliberately absent rather than present as placeholders.
|
||||
/// A session records what identifies it, what was negotiated for it, what its
|
||||
/// accepted echo requests have added up to, how much reply allowance it has
|
||||
/// left, and how long it has to live.
|
||||
///
|
||||
/// Deliberately crate-private. The remaining slices are all but certain to add
|
||||
/// and restructure fields here, and nothing outside the crate needs a session;
|
||||
/// The three mutable groups are kept apart because they move on different
|
||||
/// events: [`ReceiveState`] advances only for an echo that is answered,
|
||||
/// [`RateState`] for every echo that is judged, and [`LifetimeState`] for both —
|
||||
/// a rate-limited echo refreshes the idle deadline while advancing no statistic.
|
||||
///
|
||||
/// Deliberately crate-private. The remaining slices may still add and
|
||||
/// restructure fields here, and nothing outside the crate needs a session;
|
||||
/// publishing internal session state would buy nothing and commit us to a shape
|
||||
/// we are about to change.
|
||||
/// we may yet change.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Session {
|
||||
peer: SocketAddr,
|
||||
params: Params,
|
||||
receive: ReceiveState,
|
||||
rate: RateState,
|
||||
lifetime: LifetimeState,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) fn new(peer: SocketAddr, params: Params) -> Self {
|
||||
/// Creates a session for parameters that have already been negotiated and
|
||||
/// found executable, as of monotonic instant `now_ns`.
|
||||
///
|
||||
/// Rate policy is derived from the configuration and the *negotiated*
|
||||
/// parameters once, here, rather than recomputed per echo — see
|
||||
/// [`RateState::new`] for why the two together decide it. The idle deadline
|
||||
/// starts running immediately: a session that never carries an echo request
|
||||
/// ages out like any other.
|
||||
pub(crate) fn new(
|
||||
peer: SocketAddr,
|
||||
params: Params,
|
||||
config: &ServerConfig,
|
||||
now_ns: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
rate: RateState::new(config, ¶ms, now_ns),
|
||||
lifetime: LifetimeState::opened_at(now_ns),
|
||||
peer,
|
||||
params,
|
||||
receive: ReceiveState::default(),
|
||||
@@ -69,6 +103,36 @@ impl Session {
|
||||
pub(crate) fn commit_receive_state(&mut self, next: ReceiveState) {
|
||||
self.receive = next;
|
||||
}
|
||||
|
||||
/// This session's reply allowance as it stands.
|
||||
pub(crate) fn rate_state(&self) -> RateState {
|
||||
self.rate
|
||||
}
|
||||
|
||||
/// Adopts the allowance computed for a judged echo request.
|
||||
///
|
||||
/// Unlike the receive state, this is committed for a *rate-limited* request
|
||||
/// too: declining to answer is itself bookkeeping the limiter has to keep,
|
||||
/// or the replenishment it just accounted for would be recomputed from a
|
||||
/// stale instant on the next request.
|
||||
pub(crate) fn commit_rate_state(&mut self, next: RateState) {
|
||||
self.rate = next;
|
||||
}
|
||||
|
||||
/// How long this session has to live, and when its test duration started.
|
||||
pub(crate) fn lifetime_state(&self) -> LifetimeState {
|
||||
self.lifetime
|
||||
}
|
||||
|
||||
/// Adopts the lifetime state computed for a request.
|
||||
pub(crate) fn commit_lifetime_state(&mut self, next: LifetimeState) {
|
||||
self.lifetime = next;
|
||||
}
|
||||
|
||||
/// Whether this session has gone `idle_timeout_ns` without an echo request.
|
||||
pub(crate) fn is_idle_expired(&self, now_ns: i64, idle_timeout_ns: i64) -> bool {
|
||||
self.lifetime.is_idle_expired(now_ns, idle_timeout_ns)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a session's accepted echo requests have added up to.
|
||||
@@ -138,3 +202,231 @@ impl ReceiveState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One session's reply allowance, and the policy that replenishes it.
|
||||
///
|
||||
/// The externally visible behavior is what the clean specification's Section 9.3
|
||||
/// records: a session starts with its full burst allowance, spends one unit per
|
||||
/// echo it is answered, replenishes at one unit per refill interval and never
|
||||
/// exceeds the burst, and an echo arriving with no allowance gets no reply and
|
||||
/// advances no reception statistic. The specification is explicit that a
|
||||
/// token bucket is a *black-box inference* about the reference server, so this
|
||||
/// representation is an implementation choice, not a protocol requirement — any
|
||||
/// structure producing those outcomes would do.
|
||||
///
|
||||
/// The policy is copied in per session rather than read from the configuration
|
||||
/// per echo, because the refill interval depends on what that session
|
||||
/// negotiated. Sibling sessions therefore limit independently, as they do
|
||||
/// upstream.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct RateState {
|
||||
/// The most allowance this session may hold.
|
||||
burst: u32,
|
||||
/// How long one unit of allowance takes to accrue. Zero means no
|
||||
/// time-based throttling at all.
|
||||
interval_ns: i64,
|
||||
/// Allowance available now, never above `burst`.
|
||||
allowance: u32,
|
||||
/// The monotonic instant `allowance` was last accounted at.
|
||||
refilled_at_ns: i64,
|
||||
}
|
||||
|
||||
impl RateState {
|
||||
/// The full allowance a new session starts with, and the policy it will
|
||||
/// replenish under.
|
||||
///
|
||||
/// **The refill interval is the shorter of the configured minimum send
|
||||
/// interval and the interval this session actually negotiated.** Normally
|
||||
/// they are the configured minimum: a 10 ms minimum against a 1 s
|
||||
/// negotiated interval refills every 10 ms, which is the ordinary reference
|
||||
/// behavior. They differ only where negotiation's idle cap pulled the
|
||||
/// returned interval *below* the configured minimum — a 5 s minimum against
|
||||
/// an 8 s idle timeout returns 2 s — and there the negotiated value wins.
|
||||
///
|
||||
/// That is a deliberate `irtt-rs` divergence. The clean specification
|
||||
/// records the reference server replenishing on the configured minimum
|
||||
/// regardless, so a fully conforming client sending at the 2 s interval it
|
||||
/// was handed is rate-limited every time by a 5 s allowance. A server must
|
||||
/// not enforce a cadence it did not advertise, so this one refills at
|
||||
/// whichever cadence it told the client it would accept.
|
||||
///
|
||||
/// Zero on either side is not throttling. A zero configured minimum
|
||||
/// disables the time-based floor outright, and a session with no negotiated
|
||||
/// interval — an absent Interval, which only survives negotiation when the
|
||||
/// floor is zero as well — has no advertised cadence to honor, so the
|
||||
/// configured minimum stands alone. A zero refill interval means every
|
||||
/// otherwise admissible echo is allowed; a zero *burst* still means none is.
|
||||
fn new(config: &ServerConfig, params: &Params, now_ns: i64) -> Self {
|
||||
let configured = saturating_ns(config.min_send_interval());
|
||||
let interval_ns = if configured <= 0 || params.interval_ns <= 0 {
|
||||
configured
|
||||
} else {
|
||||
configured.min(params.interval_ns)
|
||||
};
|
||||
Self {
|
||||
burst: config.burst_allowance(),
|
||||
interval_ns,
|
||||
allowance: config.burst_allowance(),
|
||||
refilled_at_ns: now_ns,
|
||||
}
|
||||
}
|
||||
|
||||
/// Judges one echo request arriving at monotonic instant `now_ns`.
|
||||
///
|
||||
/// Pure, like [`ReceiveState::accepted`]: it returns the state the caller
|
||||
/// should commit rather than mutating, so an allowed request can have its
|
||||
/// reply built and encoded before any of the session moves. Both outcomes
|
||||
/// carry a next state, because replenishment is accounted for whether or not
|
||||
/// the request is served.
|
||||
pub(crate) fn charge(mut self, now_ns: i64) -> RateDecision {
|
||||
if self.burst == 0 {
|
||||
// No allowance exists to replenish into, so nothing accrues and
|
||||
// nothing is served. This is not a synonym for "unlimited".
|
||||
return RateDecision::Limited { next: self };
|
||||
}
|
||||
if self.interval_ns == 0 {
|
||||
// No time-based throttling: allowance never has to be spent,
|
||||
// because it never has to be waited for.
|
||||
return RateDecision::Allowed { next: self };
|
||||
}
|
||||
self.replenish(now_ns);
|
||||
if self.allowance == 0 {
|
||||
return RateDecision::Limited { next: self };
|
||||
}
|
||||
self.allowance -= 1;
|
||||
RateDecision::Allowed { next: self }
|
||||
}
|
||||
|
||||
/// Credits whatever whole intervals have elapsed, up to the burst.
|
||||
fn replenish(&mut self, now_ns: i64) {
|
||||
if self.allowance >= self.burst {
|
||||
// Already full. Idle time is not bankable, so the next unit is
|
||||
// measured from now rather than from a stretch the session spent
|
||||
// sending nothing.
|
||||
self.allowance = self.burst;
|
||||
self.refilled_at_ns = now_ns;
|
||||
return;
|
||||
}
|
||||
// Saturating, so a monotonic reading that somehow ran backwards yields a
|
||||
// negative elapsed time and credits nothing rather than panicking.
|
||||
let elapsed = now_ns.saturating_sub(self.refilled_at_ns);
|
||||
if elapsed < self.interval_ns {
|
||||
return;
|
||||
}
|
||||
let gained = elapsed / self.interval_ns;
|
||||
let allowance = self
|
||||
.allowance
|
||||
.saturating_add(u32::try_from(gained).unwrap_or(u32::MAX))
|
||||
.min(self.burst);
|
||||
self.refilled_at_ns = if allowance == self.burst {
|
||||
now_ns
|
||||
} else {
|
||||
// Carry the unspent remainder of the interval, so a session sending
|
||||
// at exactly its cadence does not lose a fraction of an interval on
|
||||
// every request and drift into being limited.
|
||||
self.refilled_at_ns
|
||||
.saturating_add(gained.saturating_mul(self.interval_ns))
|
||||
};
|
||||
self.allowance = allowance;
|
||||
}
|
||||
}
|
||||
|
||||
/// What the rate limiter decided about one echo request.
|
||||
///
|
||||
/// Both variants carry the state to commit; they differ in whether a reply is
|
||||
/// owed. `Limited` is a silent drop — the protocol has no rejection response —
|
||||
/// and it must leave the reception statistics exactly where they were.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RateDecision {
|
||||
Allowed { next: RateState },
|
||||
Limited { next: RateState },
|
||||
}
|
||||
|
||||
/// How long a session has left, and when its test duration started running.
|
||||
///
|
||||
/// Both instants are monotonic. Lifetime decisions never consult the wall
|
||||
/// clock: a session must not outlive or predecease its deadline because an
|
||||
/// administrator or NTP stepped the host clock, and the wall readings a reply
|
||||
/// carries are a separate concern with the opposite requirement — that they be
|
||||
/// reported honestly, corrections and all.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct LifetimeState {
|
||||
/// When this session last did something that keeps it alive: it was opened,
|
||||
/// it had an echo answered, or it had one rate-limited.
|
||||
last_activity_ns: i64,
|
||||
/// When this session's first echo request was actually *served*, if one has
|
||||
/// been. The maximum test duration is measured from here.
|
||||
first_served_echo_ns: Option<i64>,
|
||||
}
|
||||
|
||||
impl LifetimeState {
|
||||
/// The state of a session at the moment it is created.
|
||||
///
|
||||
/// The idle deadline runs from the open, which is a deliberate `irtt-rs`
|
||||
/// divergence: the clean evidence records the reference server starting it
|
||||
/// at the first echo request instead, so a session that never carries one
|
||||
/// never expires. A session is created by a single unauthenticated
|
||||
/// datagram, so that is an unbounded-state hazard rather than an
|
||||
/// interoperability feature, and the specification recommends against
|
||||
/// reproducing it.
|
||||
fn opened_at(now_ns: i64) -> Self {
|
||||
Self {
|
||||
last_activity_ns: now_ns,
|
||||
first_served_echo_ns: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this session has gone `idle_timeout_ns` without an echo request.
|
||||
///
|
||||
/// The deadline is exact and the comparison inclusive: at
|
||||
/// `last_activity + idle_timeout` the session is already gone. `irtt-rs`
|
||||
/// deliberately does not reproduce the reference server's additional
|
||||
/// five-second grace, nor its lazy final reply to the first echo that finds
|
||||
/// an expired session. The specification records both as observed upstream
|
||||
/// policy and states explicitly that the one interoperability constraint is
|
||||
/// negative — a server must never *signal* expiry — which silence satisfies.
|
||||
///
|
||||
/// A zero timeout expires a session at the first evaluation. It is not a
|
||||
/// synonym for "never expire".
|
||||
fn is_idle_expired(&self, now_ns: i64, idle_timeout_ns: i64) -> bool {
|
||||
now_ns.saturating_sub(self.last_activity_ns) >= idle_timeout_ns
|
||||
}
|
||||
|
||||
/// Whether an echo arriving now is the one that should carry Close.
|
||||
///
|
||||
/// The deadline is `first served echo + maximum + `
|
||||
/// [`MAX_DURATION_CLOSE_GRACE`], so it does not exist at all until an echo
|
||||
/// has actually been served: neither the open, nor an echo that was
|
||||
/// rejected, nor one that was rate-limited starts it. That origin is
|
||||
/// externally measured behavior, not an inference.
|
||||
pub(crate) fn max_duration_reached(&self, now_ns: i64, max_duration_ns: i64) -> bool {
|
||||
self.first_served_echo_ns.is_some_and(|first| {
|
||||
let deadline = first
|
||||
.saturating_add(max_duration_ns)
|
||||
.saturating_add(saturating_ns(MAX_DURATION_CLOSE_GRACE));
|
||||
now_ns >= deadline
|
||||
})
|
||||
}
|
||||
|
||||
/// The state after an echo that kept the session alive without being
|
||||
/// served — which is to say, one that was rate-limited.
|
||||
///
|
||||
/// This is the one drop class the clean evidence records as refreshing the
|
||||
/// idle deadline, and it was established against every other tested class,
|
||||
/// which does not. It does **not** move the maximum-duration origin: a
|
||||
/// request that was never answered cannot start the test.
|
||||
pub(crate) fn refreshed(mut self, now_ns: i64) -> Self {
|
||||
self.last_activity_ns = now_ns;
|
||||
self
|
||||
}
|
||||
|
||||
/// The state after an echo that is about to be answered.
|
||||
///
|
||||
/// Sets the maximum-duration origin if this is the session's first served
|
||||
/// echo, and refreshes the idle deadline either way.
|
||||
pub(crate) fn served(mut self, now_ns: i64) -> Self {
|
||||
self.last_activity_ns = now_ns;
|
||||
self.first_served_echo_ns.get_or_insert(now_ns);
|
||||
self
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user