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(¶ms, &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}