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