Skip to main content

irtt_server/
core.rs

1use std::{
2    collections::HashMap,
3    net::SocketAddr,
4    time::{Duration, SystemTime},
5};
6
7use irtt_proto::{
8    decode_request, echo_packet_len, encode_echo_reply, encode_open_reply, verify_packet_hmac,
9    Clock, DecodedParams, DecodedRequestKind, EchoReply, OpenReply, Params, StampAt,
10    TimestampFields, FLAG_CLOSE, FLAG_OPEN, FLAG_REPLY,
11};
12
13use crate::{
14    clock::{saturating_ns, wall_ns_of, ClockSample, ClockSource, SystemClock},
15    config::ServerConfig,
16    error::ServerError,
17    fill::{echo_payload_len, negotiate_server_fill},
18    negotiate::negotiate_params,
19    session::{RateDecision, Session},
20    token::{OsTokenSource, TokenSource, TOKEN_ATTEMPTS},
21};
22
23/// The deterministic protocol and session engine of the server.
24///
25/// `ServerCore` owns admission, negotiation, the session table and reply
26/// construction. It performs no I/O: a caller hands it a source endpoint and
27/// the bytes of one received datagram, and gets back the bytes to send to that
28/// endpoint, or nothing. Everything it does is a pure function of its state and
29/// that input, except for drawing session tokens and sampling the clock an echo
30/// reply's timestamps come from — both of which are private injected seams, so
31/// the whole engine stays deterministically testable.
32///
33/// The Tokio runtime wraps it directly:
34///
35/// ```text
36/// recv_from -> core.handle_datagram(peer, packet) -> Some(reply) -> send_to
37///                                                 -> None        -> send nothing
38/// ```
39///
40/// A reply is an [`OutboundDatagram`]: the bytes to send plus the transport
41/// policy that sending them requires. The core is where session policy lives, so
42/// it hands that policy out with the packet rather than leaving the runtime to
43/// rediscover it from the session table or from the packet it is about to send.
44///
45/// It is public as a low-level engine for a pre-1.0 crate, not as a promise of
46/// runtime independence. The server is intentionally Tokio-native; this is not
47/// a transport abstraction, and there is no blocking or alternate-runtime
48/// counterpart.
49///
50/// # Scope
51///
52/// The core implements open handling, session creation and negotiation,
53/// normal echo processing, per-session rate limiting, idle expiry,
54/// maximum-duration close and client-initiated close. It decides the traffic
55/// class each reply is to be sent with; actually applying it to a socket is the
56/// runtime's job. It also settles each session's server fill and generates the
57/// payload bytes of every echo reply. Incoming authenticated requests enforce logical
58/// expiry exactly; the runtime also calls a private maintenance hook to reclaim
59/// idle sessions when no traffic arrives.
60#[derive(Debug)]
61pub struct ServerCore {
62    config: ServerConfig,
63    sessions: HashMap<u64, Session>,
64    tokens: Box<dyn TokenSource>,
65    clock: Box<dyn ClockSource>,
66}
67
68impl ServerCore {
69    /// Creates a server core drawing session tokens from the operating system's
70    /// random source and timestamps from the system clock.
71    #[must_use]
72    pub fn new(config: ServerConfig) -> Self {
73        Self::with_token_source(config, Box::new(OsTokenSource))
74    }
75
76    /// A core with a chosen token source and the production clock, which is
77    /// what tests that care about session identity but not about timestamp
78    /// values want.
79    pub(crate) fn with_token_source(config: ServerConfig, tokens: Box<dyn TokenSource>) -> Self {
80        Self::with_sources(config, tokens, Box::new(SystemClock::new()))
81    }
82
83    /// A core with both nondeterministic sources chosen.
84    pub(crate) fn with_sources(
85        config: ServerConfig,
86        tokens: Box<dyn TokenSource>,
87        clock: Box<dyn ClockSource>,
88    ) -> Self {
89        Self {
90            config,
91            sessions: HashMap::new(),
92            tokens,
93            clock,
94        }
95    }
96
97    /// This core's configuration.
98    #[must_use]
99    pub fn config(&self) -> &ServerConfig {
100        &self.config
101    }
102
103    /// The number of live sessions.
104    ///
105    /// This is the observable a caller genuinely needs — how much session state
106    /// the server is holding against its bound. The sessions themselves stay
107    /// crate-private until their shape settles.
108    #[must_use]
109    pub fn session_count(&self) -> usize {
110        self.sessions.len()
111    }
112
113    /// Looks up a live session by token.
114    ///
115    /// This is a lookup by token alone and is therefore *not* an admission
116    /// check: a request is bound to a session only when the token matches and
117    /// it arrived from the session's exact endpoint. Callers pair it with an
118    /// endpoint comparison, as close handling does.
119    pub(crate) fn session(&self, token: u64) -> Option<&Session> {
120        self.sessions.get(&token)
121    }
122
123    /// Handles one received datagram, returning the reply to send, if any.
124    ///
125    /// A reply is an [`OutboundDatagram`]: the encoded packet and the raw
126    /// traffic class it must be sent with. Open and no-test replies are always
127    /// unmarked, and an echo reply — including one carrying the
128    /// maximum-duration close — takes its session's negotiated class. The
129    /// runtime applies that class to the socket before sending; nothing here
130    /// touches a socket, and a class this core hands out is a request, not a
131    /// promise that the host can honor it.
132    ///
133    /// `Ok(None)` means "answer nothing". The protocol defines no error reply,
134    /// no reset and no NACK, so every rejection is a silent discard and a
135    /// client distinguishes "rejected" from "lost" only by timing out. A
136    /// datagram is discarded when it is structurally invalid, carries the reply
137    /// flag, disagrees with this server's authentication configuration, fails
138    /// authentication, carries a malformed or out-of-range open parameter
139    /// payload, negotiates to parameters the server could not execute, would
140    /// exceed the session bound, is longer than the configured maximum packet
141    /// length, or names no live session from the endpoint that opened it. None
142    /// of that mutates any live session.
143    ///
144    /// `Ok(None)` is not only a rejection, though. A close request that does
145    /// release its session is answered with nothing as well, because protocol
146    /// version 1 defines no acknowledgement for one — and so is an echo that
147    /// found its session's rate allowance spent, which advances no statistic
148    /// but does keep the session alive.
149    ///
150    /// Sessions whose idle deadline has passed are released before the request
151    /// is dispatched, so a request may find the session it names already gone,
152    /// and one request may release an unrelated session. Expiry is never
153    /// signalled; a client sees only silence.
154    ///
155    /// Authentication is checked before open parameters are decoded and before
156    /// any session is looked up. That ordering is deliberate: a packet that
157    /// fails authentication is discarded without parsing untrusted parameter
158    /// data or consulting the session table, and no reply behavior may reveal
159    /// which stage rejected it.
160    ///
161    /// Every instant this reports is one the core sampled itself: a caller hands
162    /// over bytes and an endpoint, not timings. The Tokio runtime, which does
163    /// have transport metadata, reaches the same implementation through a
164    /// crate-private entry point that may add a kernel-observed arrival time;
165    /// this path behaves exactly as it does with none.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`ServerError`] only for an internal failure — the random source
170    /// failing, no unique token being found within the retry budget, or a
171    /// server-generated reply failing to encode. Rejected input is never an
172    /// error.
173    pub fn handle_datagram(
174        &mut self,
175        peer: SocketAddr,
176        packet: &[u8],
177    ) -> Result<Option<OutboundDatagram>, ServerError> {
178        self.process(peer, packet, None)
179    }
180
181    /// Handles one received datagram together with what the transport observed
182    /// about its arrival.
183    ///
184    /// This is the entry point the Tokio runtime uses, and the only difference
185    /// from [`handle_datagram`](Self::handle_datagram) is `kernel_rx_timestamp`:
186    /// a wall-clock arrival instant the kernel recorded for this datagram, where
187    /// the listener could obtain one. Both paths run the same implementation,
188    /// and `None` reproduces the public path exactly.
189    ///
190    /// The observation is measurement input and nothing else. It may become the
191    /// receive wall value of an echo reply that negotiated one, and it can never
192    /// reach lifecycle, rate or deadline policy, the monotonic domain, or a
193    /// midpoint — those stay on the core's own paired userspace sample. An
194    /// implausible or unrepresentable value is not a reason to reject the
195    /// datagram; it simply is not used. See
196    /// [`preferred_receive_wall_ns`].
197    ///
198    /// It is crate-private on purpose: transport metadata reaches the core by
199    /// being passed with the datagram it describes, and there is no second
200    /// public API for it.
201    ///
202    /// # Errors
203    ///
204    /// As [`handle_datagram`](Self::handle_datagram).
205    pub(crate) fn handle_received_datagram(
206        &mut self,
207        peer: SocketAddr,
208        packet: &[u8],
209        kernel_rx_timestamp: Option<SystemTime>,
210    ) -> Result<Option<OutboundDatagram>, ServerError> {
211        self.process(peer, packet, kernel_rx_timestamp)
212    }
213
214    /// The one implementation behind both receive entry points.
215    fn process(
216        &mut self,
217        peer: SocketAddr,
218        packet: &[u8],
219        kernel_rx_timestamp: Option<SystemTime>,
220    ) -> Result<Option<OutboundDatagram>, ServerError> {
221        let Ok(request) = decode_request(packet) else {
222            return Ok(None);
223        };
224        // An echo's arrival instant is sampled the moment the datagram is known
225        // to be one, so that everything the reply's receive timestamp should
226        // bracket — authentication, the session lookup, the receive-state
227        // transition and encoding — happens after it. A datagram rejected later
228        // has consumed one clock sample and nothing else; sampling is not
229        // protocol state, and it is a strictly better trade than looking a
230        // session up before authentication to discover whether it even wanted
231        // timestamps.
232        let kind = self.classify(&request.kind, packet.len(), kernel_rx_timestamp);
233
234        // The HMAC flag must agree with this server's configuration in both
235        // directions of mismatch, and the MAC itself is verified separately.
236        if request.hmac_present != self.config.hmac_key().is_some() {
237            return Ok(None);
238        }
239        if let Some(key) = self.config.hmac_key() {
240            if verify_packet_hmac(key, packet).is_err() {
241                return Ok(None);
242            }
243        }
244
245        // One monotonic instant per authenticated, structurally valid request,
246        // which is the request's whole lifecycle time: what expiry is judged
247        // against, what a new session's deadlines start from, and what the rate
248        // limiter and maximum-duration deadline read. An echo already has one —
249        // the instant its reply will report as the receive time — and taking a
250        // second would let a reply's timestamps disagree with the policy
251        // decisions made about the very same datagram.
252        let now_ns = match kind {
253            ClassifiedKind::Echo(ref echo) => echo.received_at.mono_ns,
254            _ => self.clock.sample().mono_ns,
255        };
256        self.release_expired_sessions(now_ns);
257
258        match kind {
259            ClassifiedKind::Open { no_test, params } => {
260                self.handle_open(peer, no_test, params, now_ns)
261            }
262            ClassifiedKind::Close { token } => {
263                self.handle_close(peer, token);
264                Ok(None)
265            }
266            ClassifiedKind::Echo(echo) => self.handle_echo(peer, echo),
267        }
268    }
269
270    /// Reclaims sessions whose idle deadline has passed.
271    ///
272    /// The Tokio runtime calls this for physical background reclamation. Every
273    /// authenticated structurally valid datagram still performs the same sweep
274    /// before dispatch, so the timer cadence never weakens logical expiry.
275    pub(crate) fn maintain(&mut self) {
276        let now_ns = self.clock.sample().mono_ns;
277        self.release_expired_sessions(now_ns);
278    }
279
280    /// Drops every session whose idle deadline has passed, as of `now_ns`.
281    ///
282    /// Expiry is *logical* — a session is expired the instant its deadline
283    /// passes. Authenticated, structurally valid requests run this sweep before
284    /// dispatch, while the runtime's maintenance timer also invokes it for
285    /// physical reclamation when no traffic arrives. The table remains bounded,
286    /// and a stale session cannot deny capacity to a live one.
287    ///
288    /// The sweep is global and runs before the request is dispatched, so a
289    /// request touching one session can release another. That is deliberate:
290    /// expiry is a resource decision about the table, not a side effect of what
291    /// the current datagram happens to name. It also means a *no-test* open —
292    /// which creates nothing of its own — reclaims expired sessions like any
293    /// other open, where the reference server was observed not to. Both are
294    /// upstream policy the clean specification declines to require, and a
295    /// client cannot tell the difference: expiry is never signalled.
296    fn release_expired_sessions(&mut self, now_ns: i64) {
297        let idle_timeout_ns = saturating_ns(self.config.idle_timeout());
298        self.sessions
299            .retain(|_, session| !session.is_idle_expired(now_ns, idle_timeout_ns));
300    }
301
302    /// Pairs a structurally decoded request with the clock sample an echo
303    /// needs, leaving every other kind untouched.
304    ///
305    /// An echo also settles here which wall instant its reply may report as the
306    /// arrival time, because that is the moment both candidates are in hand.
307    /// `kernel_rx_timestamp` is ignored for every other kind: an open or a close
308    /// emits no receive timestamp, so having observed one changes nothing about
309    /// it.
310    fn classify<'a>(
311        &mut self,
312        kind: &DecodedRequestKind<'a>,
313        datagram_len: usize,
314        kernel_rx_timestamp: Option<SystemTime>,
315    ) -> ClassifiedKind<'a> {
316        match *kind {
317            DecodedRequestKind::Open { no_test, params } => {
318                ClassifiedKind::Open { no_test, params }
319            }
320            DecodedRequestKind::Close { token } => ClassifiedKind::Close { token },
321            // Everything after the sequence number is opaque, so the tail is
322            // read no further than its length.
323            DecodedRequestKind::Echo {
324                token, sequence, ..
325            } => {
326                let received_at = self.clock.sample();
327                ClassifiedKind::Echo(EchoRequest {
328                    token,
329                    sequence,
330                    datagram_len,
331                    received_at,
332                    receive_wall_ns: preferred_receive_wall_ns(kernel_rx_timestamp, received_at),
333                })
334            }
335        }
336    }
337
338    /// Answers an echo request that names one of this server's sessions.
339    ///
340    /// Admission runs before any state moves, and each step is a silent
341    /// discard: the datagram must not be longer than
342    /// [`max_packet_length`](ServerConfig::max_packet_length), its token must
343    /// name a live session, and it must have arrived from that session's own
344    /// endpoint. Authentication was already settled by the caller, as was idle
345    /// expiry — a request reaching here found its session still alive. A
346    /// request failing any of these leaves the receive state — of this session
347    /// and of every other — exactly where it was.
348    ///
349    /// Only the token and the sequence number mean anything. The request's own
350    /// length is judged against the configured maximum and then forgotten: it
351    /// does not have to equal the negotiated length, and it never sizes the
352    /// reply, which is laid out and sized from the session's negotiated
353    /// parameters alone.
354    ///
355    /// **Rate allowance is judged after admission and before the
356    /// maximum-duration deadline.** That order is the observed one and it
357    /// matters: a post-deadline echo with no allowance is simply dropped, and
358    /// the close it would have carried is not lost but handed to the next echo
359    /// that is served. A rate-limited request refreshes the session's idle
360    /// deadline — the one drop class the clean evidence records doing so — and
361    /// changes nothing else.
362    ///
363    /// **The maximum-duration close is an ordinary reply with the Close flag
364    /// added**, never a standalone packet: protocol version 1 has no other
365    /// in-band mechanism. It reports the triggering request in its statistics
366    /// exactly as any reply would, and the session is released once it has
367    /// encoded, so every later request is dropped as an unknown token.
368    ///
369    /// Every state transition is computed but not committed until the reply has
370    /// been encoded, so a reply this server failed to build cannot leave a
371    /// session claiming to have answered a request it never did — or, for a
372    /// close, leave the session released with no reply carrying the flag.
373    fn handle_echo(
374        &mut self,
375        peer: SocketAddr,
376        request: EchoRequest,
377    ) -> Result<Option<OutboundDatagram>, ServerError> {
378        // Resource policy, applied to the length actually received. This is
379        // `irtt-rs` policy rather than upstream-mechanism emulation: there is
380        // no interface-MTU effective length here, just the datagram the caller
381        // handed over. The comparison is strict — a request of exactly the
382        // maximum is served.
383        if request.datagram_len > self.config.max_packet_length() {
384            return Ok(None);
385        }
386        let Some(session) = self.sessions.get_mut(&request.token) else {
387            return Ok(None);
388        };
389        if !same_endpoint(session.peer(), peer) {
390            return Ok(None);
391        }
392
393        let now_ns = request.received_at.mono_ns;
394        let next_rate = match session.rate_state().charge(now_ns) {
395            RateDecision::Limited { next } => {
396                // Silence, and no statistic moves — but the session lives on,
397                // and its idle deadline moves with it.
398                let next_lifetime = session.lifetime_state().refreshed(now_ns);
399                session.commit_rate_state(next);
400                session.commit_lifetime_state(next_lifetime);
401                return Ok(None);
402            }
403            RateDecision::Allowed { next } => next,
404        };
405
406        // Only a session that has already served an echo has a deadline at all,
407        // so this can never fire on the request that starts the test.
408        let closing = self.config.max_test_duration().is_some_and(|maximum| {
409            session
410                .lifetime_state()
411                .max_duration_reached(now_ns, saturating_ns(maximum))
412        });
413
414        let next = session.receive_state().accepted(request.sequence);
415        let next_lifetime = session.lifetime_state().served(now_ns);
416        // The departure side, sampled as late as the core can: after admission
417        // and the transition, immediately before the reply is built. There is
418        // no socket here, so this is the core's departure instant rather than a
419        // kernel transmission time; the runtime slice does not need to rewrite
420        // it.
421        let sent_at = self.clock.sample();
422        let params = session.params();
423        // Fill bytes are prepared before the reply is built, so they go through
424        // the encoder's normal path and the packet's MAC covers them like every
425        // other byte. Nothing mutates a payload after encoding.
426        let payload_len = echo_payload_len(self.config.hmac_key().is_some(), params);
427        // Read before the session can be released below: a maximum-duration
428        // close is an ordinary echo reply and carries the session's marking like
429        // any other.
430        let traffic_class = transport_traffic_class(params);
431        let reply = EchoReply {
432            // Open must be clear or upstream clients abort. Close is set only
433            // for the maximum-duration limit, which is the sole trigger this
434            // server has. The authentication flag and field are the encoder's,
435            // decided by the key it is given — a close-flagged reply is
436            // authenticated by exactly the same path as any other.
437            flags: FLAG_REPLY | if closing { FLAG_CLOSE } else { 0 },
438            // Both are copied through unchanged: no token is allocated for an
439            // echo, and no sequence number is normalized or range-checked.
440            token: request.token,
441            sequence: request.sequence,
442            recv_count: params.received_stats.has_count().then_some(next.count),
443            recv_window: params.received_stats.has_window().then_some(next.window),
444            timestamps: timestamp_fields(
445                params,
446                request.received_at,
447                sent_at,
448                request.receive_wall_ns,
449            ),
450            // The session's negotiated fill, generated fresh for this reply and
451            // sized from the layout rather than from `params.length`. A `none`
452            // session contributes no bytes and the encoder's zero-fill stands,
453            // which is what keeps a no-fill reply from disclosing residue.
454            // Nothing from the request reaches here: its payload is opaque, and
455            // ServerFill describes the server's own bytes.
456            payload: session.fill().payload(payload_len),
457        };
458        let packet = encode_echo_reply(&reply, params, self.config.hmac_key())
459            .map_err(|source| ServerError::ReplyEncoding { source })?;
460
461        // The last fallible step is behind us, so the transitions can be made.
462        if closing {
463            // The reply already carries the state this request produced, so
464            // there is nothing worth committing into a session about to go. A
465            // failure above would have left it live and untouched instead.
466            self.sessions.remove(&request.token);
467        } else {
468            session.commit_rate_state(next_rate);
469            session.commit_receive_state(next);
470            session.commit_lifetime_state(next_lifetime);
471        }
472        Ok(Some(OutboundDatagram::new(packet, traffic_class)))
473    }
474
475    /// Negotiates an open request and, unless it is a no-test one, creates the
476    /// session it asks for.
477    ///
478    /// `now_ns` is the request's lifecycle instant, which becomes the new
479    /// session's idle origin: a session starts aging the moment it exists, not
480    /// when it first carries an echo, so one whose client never returns is
481    /// still reclaimed. Expired sessions were already swept, so the capacity
482    /// bound below is judged against sessions that are genuinely live.
483    fn handle_open(
484        &mut self,
485        peer: SocketAddr,
486        no_test: bool,
487        encoded_params: &[u8],
488        now_ns: i64,
489    ) -> Result<Option<OutboundDatagram>, ServerError> {
490        // The parameter decoder already rejects a truncated tag or value, a
491        // varint overflow, an out-of-range stats/stamp-at/clock enum, and a
492        // server-fill value that is oversized, short-buffered or not UTF-8.
493        let Ok(decoded) = Params::decode_with_presence(encoded_params) else {
494            return Ok(None);
495        };
496        if !open_params_are_admissible(&decoded) {
497            return Ok(None);
498        }
499        let mut params = negotiate_params(decoded.params, &self.config);
500        // Settled here, once per open, so echo processing never parses a
501        // descriptor. It returns what the session will actually fill with,
502        // which an absent or empty descriptor deliberately does not describe,
503        // and restricts `params` only where an explicit descriptor could not be
504        // honored. A no-test open runs it for the negotiated value alone and
505        // drops the mode, having no session to keep it in.
506        let fill = negotiate_server_fill(&mut params);
507        // Deliberately after negotiation, and before either open path: what has
508        // to be executable is the effective session, not the request.
509        if !negotiated_params_are_admissible(&params, &self.config) {
510            return Ok(None);
511        }
512
513        if no_test {
514            // No-test validates parameters without running a test: same
515            // negotiation, a zero token, and no state at all. It stays
516            // serviceable at capacity precisely because it creates nothing.
517            let reply = OpenReply {
518                flags: FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE,
519                token: 0,
520                params,
521            };
522            return self
523                .encode_reply(&reply)
524                .map(|packet| Some(OutboundDatagram::unmarked(packet)));
525        }
526
527        if self.sessions.len() >= self.config.max_sessions() {
528            return Ok(None);
529        }
530
531        // Every fallible step runs before the table is touched, so an internal
532        // failure cannot leave a half-created session behind.
533        let token = self.allocate_token()?;
534        let reply = OpenReply {
535            flags: FLAG_OPEN | FLAG_REPLY,
536            token,
537            params,
538        };
539        let packet = self.encode_reply(&reply)?;
540
541        // The reply is encoded before its params move into the session, so
542        // nothing is cloned to keep both.
543        self.sessions.insert(
544            token,
545            Session::new(peer, reply.params, fill, &self.config, now_ns),
546        );
547        Ok(Some(OutboundDatagram::unmarked(packet)))
548    }
549
550    /// Releases the session a close request names, if that request owns one.
551    ///
552    /// A close is bound to a session only when the token matches *and* the
553    /// datagram arrived from that session's exact endpoint, so holding a token
554    /// is not by itself authority to tear the session down: a close from a
555    /// foreign endpoint leaves it live, and one close cannot take down a
556    /// sibling session that the same peer opened separately. A token that is
557    /// unknown, already closed, or zero — zero is reserved for no-test replies
558    /// and never issued — matches nothing and leaves the table untouched.
559    ///
560    /// Nothing is sent either way, which is why this returns no reply to
561    /// encode. Protocol version 1 defines no acknowledgement for a client
562    /// close, so a delivered close is indistinguishable to the client from a
563    /// lost one, and the freed capacity is immediately reusable because the
564    /// session table is the only record of it.
565    fn handle_close(&mut self, peer: SocketAddr, token: u64) {
566        let owns_session = self
567            .session(token)
568            .is_some_and(|session| same_endpoint(session.peer(), peer));
569        if owns_session {
570            self.sessions.remove(&token);
571        }
572    }
573
574    /// Encodes a reply this server constructed. Any failure here is internal.
575    fn encode_reply(&self, reply: &OpenReply) -> Result<Vec<u8>, ServerError> {
576        encode_open_reply(reply, self.config.hmac_key())
577            .map_err(|source| ServerError::ReplyEncoding { source })
578    }
579
580    /// Draws a token that is non-zero and unique among live sessions.
581    ///
582    /// Zero is reserved for no-test replies and is never issued to a session. A
583    /// value colliding with a live token is discarded rather than allowed to
584    /// overwrite that session. Both cases redraw, up to [`TOKEN_ATTEMPTS`].
585    fn allocate_token(&mut self) -> Result<u64, ServerError> {
586        for _ in 0..TOKEN_ATTEMPTS {
587            let token = self.tokens.next_token()?;
588            if token != 0 && !self.sessions.contains_key(&token) {
589                return Ok(token);
590            }
591        }
592        Err(ServerError::TokenExhausted {
593            attempts: TOKEN_ATTEMPTS,
594        })
595    }
596}
597
598/// One reply the core prepared, together with the transport policy it must be
599/// sent under.
600///
601/// The bytes alone are not a complete instruction. A session negotiates a
602/// traffic class, and only the core knows which session a reply belongs to, so
603/// the class travels out with the packet instead of the runtime decoding the
604/// outgoing datagram again or keeping a second copy of session state.
605///
606/// [`traffic_class`](Self::traffic_class) is the **raw 8-bit IPv4 TOS / IPv6
607/// Traffic Class byte**, not a six-bit codepoint: the codepoint is its upper six
608/// bits and the low two are ECN. Zero is a value like any other and means
609/// unmarked — it is deliberately not `Option<u8>`, because a runtime sharing one
610/// socket between sessions has to apply zero as explicitly as it applies
611/// anything else, or an unmarked reply inherits the previous one's marking.
612#[derive(Debug, Clone, PartialEq, Eq)]
613pub struct OutboundDatagram {
614    bytes: Vec<u8>,
615    traffic_class: u8,
616}
617
618impl OutboundDatagram {
619    /// A reply to send with an explicit raw traffic-class byte.
620    fn new(bytes: Vec<u8>, traffic_class: u8) -> Self {
621        Self {
622            bytes,
623            traffic_class,
624        }
625    }
626
627    /// A reply to send unmarked.
628    fn unmarked(bytes: Vec<u8>) -> Self {
629        Self::new(bytes, 0)
630    }
631
632    /// The encoded reply.
633    #[must_use]
634    pub fn bytes(&self) -> &[u8] {
635        &self.bytes
636    }
637
638    /// The raw IPv4 TOS / IPv6 Traffic Class byte this reply is to be sent
639    /// with. Zero means unmarked.
640    #[must_use]
641    pub fn traffic_class(&self) -> u8 {
642        self.traffic_class
643    }
644
645    /// Consumes the datagram, yielding its encoded reply.
646    #[must_use]
647    pub fn into_bytes(self) -> Vec<u8> {
648        self.bytes
649    }
650}
651
652impl AsRef<[u8]> for OutboundDatagram {
653    fn as_ref(&self) -> &[u8] {
654        self.bytes()
655    }
656}
657
658/// The raw traffic-class byte a session's negotiated DSCP parameter asks for.
659///
660/// The wire parameter is an `i64` and this server deliberately accepts values
661/// outside `0..=255` during negotiation, returning and storing them unchanged.
662/// A value that is not a byte cannot be a TOS / Traffic Class byte, so the
663/// transport treats it as unmarked. That is **`irtt-rs` policy, not observed
664/// behavior**: the clean evidence records the reference host's handling of
665/// negative and oversized values as platform-specific and explicitly not a
666/// compatibility requirement, so there is nothing to reproduce. Zero is chosen
667/// over `as`-style truncation or two's-complement wrapping because neither
668/// −1 → 255 nor 256 → 0 is a marking the client asked for, and over refusing the
669/// session because a malformed-but-accepted open should stay usable.
670fn transport_traffic_class(params: &Params) -> u8 {
671    u8::try_from(params.dscp).unwrap_or(0)
672}
673
674/// A structurally decoded request kind, with an echo's arrival instant already
675/// sampled.
676///
677/// This mirrors [`DecodedRequestKind`] rather than replacing it: the decoder
678/// classifies the packet, and this records what the core had to capture at that
679/// moment. Keeping the two apart is what lets the clock be read before
680/// authentication without any handler having to ask whether a sample exists.
681enum ClassifiedKind<'a> {
682    Open { no_test: bool, params: &'a [u8] },
683    Close { token: u64 },
684    Echo(EchoRequest),
685}
686
687/// The whole of what an echo request means to this server.
688///
689/// Everything after the sequence number is opaque, so nothing beyond these
690/// fields is carried forward. `datagram_len` is the length the datagram
691/// actually arrived with, which the configured maximum judges; it is not
692/// required to match the negotiated length, and it never sizes the reply.
693///
694/// The two timing fields are deliberately separate and are not interchangeable.
695/// `received_at` is the core's own paired userspace instant and is the sole
696/// authority for everything about this request that is a same-host question:
697/// idle expiry, rate allowance, the maximum-duration deadline, and the
698/// monotonic timestamps a reply may carry. `receive_wall_ns` is only the
699/// wall-clock arrival value a reply may *report*, which is the kernel's reading
700/// where the transport observed a usable one. Keeping it out of `ClockSample`
701/// is the point: a kernel wall reading has no monotonic counterpart, and pairing
702/// it with `received_at.mono_ns` would claim two different physical instants
703/// were one sample.
704#[derive(Debug, Clone, Copy)]
705struct EchoRequest {
706    token: u64,
707    sequence: u32,
708    datagram_len: usize,
709    received_at: ClockSample,
710    receive_wall_ns: i64,
711}
712
713/// Largest lag behind the core's own receive sample that a kernel receive
714/// timestamp may show and still be used as a measurement endpoint.
715///
716/// This is a sanity guard rather than an expected kernel-to-userspace wakeup
717/// latency, which is orders of magnitude smaller. A reading this far behind the
718/// server's own sample most likely reflects a realtime clock discontinuity or
719/// otherwise unusable metadata, and falling back to a value this server knows it
720/// read is better than publishing a bad cross-host timing endpoint. The client
721/// applies the same bound to its own receive metadata.
722pub(crate) const MAX_KERNEL_RX_LAG: Duration = Duration::from_secs(1);
723
724/// The wall instant an echo reply should report as the request's arrival time.
725///
726/// A kernel receive timestamp is taken before the socket queue and the runtime's
727/// wakeup, so preferring it removes that delay from the upstream one-way delay a
728/// client computes. It is used only when it is plausible *for this datagram*: it
729/// must convert into the wire's signed-nanosecond vocabulary, it cannot be later
730/// than the sample that observed the same datagram, and it may not lag that
731/// sample by more than [`MAX_KERNEL_RX_LAG`]. The bound is inclusive.
732///
733/// Anything else falls back to the userspace wall reading. A failed check is
734/// never a reason to drop the datagram, refuse a reply or disturb session state:
735/// implausible metadata means the server measures as it did before the kernel
736/// offered any.
737///
738/// The comparison goes through `i128` because the difference of two `i64`
739/// nanosecond values can exceed `i64`.
740pub(crate) fn preferred_receive_wall_ns(
741    kernel_rx_timestamp: Option<SystemTime>,
742    received_at: ClockSample,
743) -> i64 {
744    let userspace_wall_ns = received_at.wall_ns;
745    let Some(kernel_wall_ns) = kernel_rx_timestamp.and_then(wall_ns_of) else {
746        return userspace_wall_ns;
747    };
748    let lag_ns = i128::from(userspace_wall_ns) - i128::from(kernel_wall_ns);
749    if (0..=i128::from(saturating_ns(MAX_KERNEL_RX_LAG))).contains(&lag_ns) {
750        kernel_wall_ns
751    } else {
752        userspace_wall_ns
753    }
754}
755
756/// Builds the timestamp fields a session negotiated, and only those.
757///
758/// [`StampAt`] selects which instants are reported and [`Clock`] selects which
759/// domains, exactly as the reply layout does, so a field this returns as `Some`
760/// is a field the encoder has a slot for. A midpoint is the per-domain mean of
761/// the two instants.
762///
763/// A single-clock midpoint therefore produces exactly one midpoint field. That
764/// is the conforming representation; upstream 0.9.1's habit of emitting both
765/// midpoint fields regardless of the negotiated clock is a defect this server
766/// deliberately does not reproduce. `irtt-proto` still *decodes* that form, so
767/// interoperating with a peer that sends it is unaffected.
768///
769/// [`Clock::Unspecified`] selects no domain and so produces no field. It cannot
770/// be reached with a non-none `StampAt` on an acknowledged session, because
771/// that combination is refused at open; nothing here needs to repair it.
772///
773/// The pair is ordered before anything is built from it, because a reply's
774/// receive instant must not be later than its send instant and the wall clock
775/// can be stepped backwards between the two readings. See
776/// [`ClockSample::not_after`]; the correction is confined to this one reply.
777///
778/// `receive_wall_ns` is the wall instant a reported arrival time should use —
779/// [`preferred_receive_wall_ns`], so the kernel's reading where there was a
780/// usable one and the userspace sample's own otherwise. It reaches exactly two
781/// fields, `recv_wall` under [`StampAt::Receive`] and under [`StampAt::Both`],
782/// and it is held to the send reading by the same rule the paired sample is.
783/// Everything else keeps the paired userspace samples:
784///
785/// - **Monotonic never moves.** A kernel wall reading has no monotonic
786///   counterpart, and inventing one would be precision the kernel never
787///   reported. So under [`Clock::Both`] a `recv_wall`/`recv_mono` pair may
788///   describe two different physical instants. That is a deliberate wire
789///   policy — each field is honest about the domain it names, and nothing
790///   computes across the two — and it is why the core does not model them as
791///   one sample.
792/// - **A midpoint is untouched.** It is the mean of the receive and send
793///   instants, so substituting only the wall receive endpoint would move
794///   `midpoint_wall` earlier by half the kernel-to-userspace delay: apparent
795///   upstream delay would improve by exactly as much as apparent downstream
796///   delay worsened, `midpoint_wall` and `midpoint_mono` would stop describing
797///   the same midpoint, and no server residency interval is recoverable from a
798///   midpoint to make up for it.
799/// - **[`StampAt::Send`] and [`StampAt::None`] report no arrival time at all**,
800///   so an observation cannot reach them.
801///
802/// One consequence is intended and worth stating: under [`Clock::Wall`] with
803/// [`StampAt::Both`], the interval a client computes from the two reported
804/// instants now spans from the kernel's arrival reading rather than from the
805/// server's wakeup, so it includes the kernel-to-userspace portion of the
806/// server's residency. Under [`Clock::Both`] the monotonic pair still yields the
807/// unchanged userspace interval.
808fn timestamp_fields(
809    params: &Params,
810    received: ClockSample,
811    sent: ClockSample,
812    receive_wall_ns: i64,
813) -> TimestampFields {
814    let received = received.not_after(sent);
815    // The reported arrival wall value obeys the same ordering rule, and needs
816    // its own application of it: it may not have come from `received`, so a
817    // backward wall step between the kernel's reading and the send sample would
818    // otherwise leave the reply claiming it was received after it was sent.
819    let receive_wall_ns = receive_wall_ns.min(sent.wall_ns);
820    let per_clock = |sample: ClockSample| {
821        (
822            params.clock.has_wall().then_some(sample.wall_ns),
823            params.clock.has_mono().then_some(sample.mono_ns),
824        )
825    };
826    let arrival = (
827        params.clock.has_wall().then_some(receive_wall_ns),
828        params.clock.has_mono().then_some(received.mono_ns),
829    );
830    let absent = (None, None);
831    let (recv, midpoint, send) = match params.stamp_at {
832        StampAt::None => (absent, absent, absent),
833        StampAt::Receive => (arrival, absent, absent),
834        StampAt::Send => (absent, absent, per_clock(sent)),
835        StampAt::Both => (arrival, absent, per_clock(sent)),
836        StampAt::Midpoint => (absent, per_clock(received.midpoint(sent)), absent),
837    };
838    TimestampFields {
839        recv_wall: recv.0,
840        recv_mono: recv.1,
841        midpoint_wall: midpoint.0,
842        midpoint_mono: midpoint.1,
843        send_wall: send.0,
844        send_mono: send.1,
845    }
846}
847
848/// Whether two source endpoints identify the same session endpoint.
849///
850/// The address family, the IP address and the UDP source port are established
851/// identity components: a request from a different port, address or family is
852/// dropped and leaves its session live.
853///
854/// The remaining two fields of [`SocketAddrV6`] are decided here rather than
855/// observed, which is why this is not simply `==`.
856///
857/// The flow label is excluded. It identifies no endpoint — it is routing
858/// metadata a sender or forwarding path may legitimately vary within one flow,
859/// and a receiver reports it only when asked to — so admitting it could strand
860/// a session its own client can then never close. It grants nothing in
861/// exchange: the token, address, port and scope are what an off-path attacker
862/// would have to guess.
863///
864/// The scope identifier is included, and that is **`irtt-rs` policy, not
865/// verified compatibility behavior**. The server specification lists the IPv6
866/// zone as an identity component, but its remaining-unknowns section records
867/// that multi-zone behavior could not be tested on a single-host platform, so
868/// no evidence settles how a reference server compares scoped addresses. Two
869/// peers reachable at the same link-local address in different zones are
870/// genuinely different peers, so treating the zone as identity is the
871/// conservative reading: the failure it prevents is one peer closing another's
872/// session, while the failure it risks is a scoped client having to reopen.
873/// Revisit if evidence ever settles the question.
874///
875/// [`SocketAddrV6`]: std::net::SocketAddrV6
876fn same_endpoint(session: SocketAddr, peer: SocketAddr) -> bool {
877    match (session, peer) {
878        (SocketAddr::V6(session), SocketAddr::V6(peer)) => {
879            session.ip() == peer.ip()
880                && session.port() == peer.port()
881                && session.scope_id() == peer.scope_id()
882        }
883        (session, peer) => session == peer,
884    }
885}
886
887/// Applies the open parameter rules the decoder cannot express.
888///
889/// This judges the *request*, before negotiation, which is why it takes the
890/// decoded form with its presence flags. Whether the resulting session is
891/// coherent is a separate question, asked after negotiation by
892/// [`negotiated_params_are_admissible`].
893///
894/// Duration and Interval are accepted when absent, taking the wire default
895/// zero, and rejected when explicitly present and not positive. The presence
896/// distinction is why the decoder reports it: testing the value alone would
897/// wrongly reject an open that simply omitted the tag, which includes the empty
898/// parameter payload.
899///
900/// Every other known parameter is already validated or deliberately
901/// unrestricted here. Protocol version is accepted at any value and rewritten
902/// during negotiation. Length and DSCP are accepted as decoded, including zero,
903/// negative and out-of-byte-range values; a DSCP the socket could never carry
904/// is negotiated unchanged and transported unmarked instead of being refused
905/// (see [`transport_traffic_class`]), and a server that disallows DSCP
906/// negotiates zero rather than rejecting the request. Unknown tags were ignored
907/// by the decoder
908/// and are not reflected in the reply.
909fn open_params_are_admissible(decoded: &DecodedParams) -> bool {
910    let admissible = |present: bool, value: i64| !present || value > 0;
911    admissible(decoded.presence.duration_ns, decoded.params.duration_ns)
912        && admissible(decoded.presence.interval_ns, decoded.params.interval_ns)
913}
914
915/// Whether the *effective* parameters describe a session this server could
916/// actually run.
917///
918/// This is the last gate before an open is acknowledged, and it asks a
919/// different question from [`open_params_are_admissible`]: not whether the
920/// request was well formed, but whether the parameters negotiation settled on
921/// are coherent. It therefore runs on the negotiated params, after
922/// [`negotiate_params`], and covers the no-test path too — a no-test open
923/// exists to tell a client what the session would be, so it must not report a
924/// session that could not exist.
925///
926/// Two rules so far.
927///
928/// **Selecting timestamps requires a specified clock.** A non-none `stamp_at`
929/// with [`Clock::Unspecified`] asks for timestamp fields from no clock at all,
930/// which lays out no timestamp field and leaves the session with a request the
931/// server cannot honor.
932///
933/// [`Clock::Unspecified`] means the Clock tag was absent — an explicit zero is
934/// already out of range for the decoder — so this cannot be reached by a
935/// conforming client, which sends a clock whenever it selects timestamps. The
936/// combination is silently rejected rather than repaired: synthesizing a clock
937/// would answer with a session the client never asked for, and rewriting
938/// `stamp_at` to none would silently drop the measurement it did ask for.
939/// Neither is the server's to decide.
940///
941/// **The session's echo datagram must fit
942/// [`max_packet_length`](ServerConfig::max_packet_length).** Negotiation already
943/// reduced an oversized Length, but that only bounds what the *parameter* asks
944/// for: the mandatory field block grows with the received statistics, the
945/// timestamps and — by 16 bytes — authentication, and can exceed the configured
946/// maximum on its own. A session whose smallest compliant reply is already too
947/// large is one the server could never answer an echo for, so it is refused at
948/// open rather than acknowledged and then found unserviceable.
949///
950/// The size comes from [`echo_packet_len`], the one packet-size authority, so
951/// this cannot drift from what an echo reply actually encodes. Its
952/// unrepresentable-length error is a rejection here, not a [`ServerError`]: a
953/// pathological requested length is remote input, not an internal failure. The
954/// default policy makes it unreachable for an acknowledged session anyway, since
955/// negotiation has already reduced the length to a representable maximum.
956///
957/// The check is deliberately *after* negotiation, which is what makes a
958/// configured timestamp allowance of
959/// [`TimestampAllowance::None`](crate::TimestampAllowance::None) accept this
960/// otherwise refused request: its effective `stamp_at` is [`StampAt::None`], so
961/// the absent clock selects nothing that was going to be reported and the
962/// session is executable exactly as negotiated.
963///
964/// [`ServerError`]: crate::ServerError
965fn negotiated_params_are_admissible(params: &Params, config: &ServerConfig) -> bool {
966    if params.stamp_at != StampAt::None && params.clock == Clock::Unspecified {
967        return false;
968    }
969    echo_packet_len(config.hmac_key().is_some(), params)
970        .is_ok_and(|len| len <= config.max_packet_length())
971}