phantom_protocol/api/udp_transport.rs
1//! `SessionTransport` impls over raw UDP (PhantomUDP). `UdpClientTransport` is an
2//! unconnected-socket client (it `send_to`s a tracked server address and `recv_from`s any
3//! source, so it can hear — and later follow — a server that migrates to a new address);
4//! `UdpServerTransport` is a per-session shim fed by the listener demux that can migrate its
5//! own send socket. Both add / strip the outer `[flags][cid]` envelope exactly as
6//! `TcpSessionTransport` adds / strips its 4-byte length prefix, so `run_data_pump` /
7//! `run_client_handshake` / `drive_server_handshake` are reused.
8
9use crate::api::session::{FramePhase, SessionTransport};
10use crate::errors::CoreError;
11use crate::transport::phantom_udp::datagram::{encode_datagrams, push_datagram, FragmentAssembler};
12use crate::transport::phantom_udp::envelope::{ConnId, PacketType, PATH_MTU};
13// `HDR_LEN` is referenced only by the test module (`super::HDR_LEN`); a plain top-level
14// import trips clippy's `--lib` unused-import check, which excludes `#[cfg(test)]` code.
15#[cfg(test)]
16use crate::transport::phantom_udp::envelope::HDR_LEN;
17use arc_swap::ArcSwap;
18use bytes::Bytes;
19use std::net::SocketAddr;
20use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering};
21use std::sync::Arc;
22use std::time::Duration;
23use tokio::net::UdpSocket;
24use tokio::sync::{mpsc, Mutex};
25
26/// Retransmit timeout for the Handshake phase stop-and-wait shim.
27const HANDSHAKE_RTO: Duration = Duration::from_millis(400);
28/// Max Handshake-phase retransmits before giving up (the outer 10s connect deadline still applies).
29const MAX_HANDSHAKE_RETX: u32 = 6;
30
31const PHASE_HANDSHAKE: u8 = 0;
32const PHASE_ESTABLISHED: u8 = 1;
33
34/// Outcome of classifying a UDP `recv_from` result (M-6).
35enum RecvAction {
36 /// A datagram of this many bytes arrived from this source (the source is needed for
37 /// server-migration candidate detection — M-1).
38 Got(usize, SocketAddr),
39 /// An ICMP-induced *advisory* error — retry, do not tear the session down.
40 Retry,
41 /// A genuine, fatal socket error.
42 Fatal(std::io::Error),
43}
44
45/// Whether a UDP `recv` error is an ICMP-induced *advisory* condition (RFC 8085 §5.5 /
46/// RFC 9000 §14.2). The client socket is unconnected, so the kernel rarely attributes an
47/// ICMP error to a `recv_from` (it cannot map it to a specific unconnected send) — this
48/// handling is mostly dormant there but retained for the platforms that do surface one. A
49/// single such error — which an off-path attacker can forge (the UDP analogue of a forged
50/// RST) — must NOT kill the session.
51fn is_advisory_recv_error(e: &std::io::Error) -> bool {
52 use std::io::ErrorKind;
53 // ConnectionRefused (ICMP port-unreachable — the audit's M-6 case) and ConnectionReset are
54 // matched via the portable `ErrorKind`. Host/network-unreachable are matched by raw errno
55 // instead, so the check stays uniform across platforms regardless of whether the OS maps
56 // them to the named `ErrorKind`s (Linux: EHOSTUNREACH = 113, ENETUNREACH = 101).
57 if matches!(
58 e.kind(),
59 ErrorKind::ConnectionRefused | ErrorKind::ConnectionReset
60 ) {
61 return true;
62 }
63 #[cfg(target_os = "linux")]
64 if let Some(errno) = e.raw_os_error() {
65 return errno == 113 || errno == 101;
66 }
67 false
68}
69
70/// Classify a `recv_from` result: an advisory ICMP error is retried, a genuine error is
71/// fatal (M-6); a datagram carries its source for migration-candidate detection.
72fn classify_recv(r: std::io::Result<(usize, SocketAddr)>) -> RecvAction {
73 match r {
74 Ok((n, src)) => RecvAction::Got(n, src),
75 Err(e) if is_advisory_recv_error(&e) => RecvAction::Retry,
76 Err(e) => RecvAction::Fatal(e),
77 }
78}
79
80pub struct UdpClientTransport {
81 /// Active send/recv socket. `ArcSwap` so `migrate()` can atomically rebind to a
82 /// new local socket (Phase 4 / P4.2b) without re-handshake; `send_bytes` and the
83 /// recv loop reload it per call.
84 socket: ArcSwap<UdpSocket>,
85 /// The previous socket, retained during a migration overlap so the client keeps
86 /// receiving downstream data on the old path until the new path shows life
87 /// (D7 / broken-rebind safety). `None` outside an overlap; dropped on the first
88 /// frame received on the new socket.
89 prev_socket: ArcSwap<Option<Arc<UdpSocket>>>,
90 /// The server remote this client `send_to`s. `ArcSwap` because the socket is
91 /// unconnected (the destination is explicit per send rather than a kernel-pinned connect
92 /// peer) AND because a server-migration *follow* re-points it to a validated new server
93 /// address without a re-handshake: when the server moves, the client path-validates the
94 /// new source and [`promote_candidate`](SessionTransport::promote_candidate) stores it
95 /// here, so subsequent c2s flows to the new address. Read on every `send_bytes`.
96 server_addr: ArcSwap<SocketAddr>,
97 /// The bootstrap (handshake) ConnId — a random lifetime id stamped until the
98 /// session sets the rotating chain via [`set_outbound_cid`](SessionTransport::set_outbound_cid).
99 cid: ConnId,
100 /// The rotating routing CID set at the handshake → data-pump boundary (ε /
101 /// WIRE v5). `None` during the handshake (the bootstrap `cid` is stamped);
102 /// `Some(CID_0)` once the session sets it, after which every datagram stamps it.
103 established_cid: ArcSwap<Option<ConnId>>,
104 phase: AtomicU8,
105 next_packet_id: AtomicU32,
106 /// Datagrams of the most recently sent frame, retransmitted on RTO during Handshake.
107 last_sent: Mutex<Vec<Vec<u8>>>,
108 reasm: Mutex<FragmentAssembler>,
109 /// Server-migration candidate (the mirror of the server's client-migration candidate): a
110 /// NEW server source (≠ `server_addr`) observed for this session. The session
111 /// path-validates it before any switch; `promote_candidate` then stores it as the new
112 /// `server_addr`. `None` until a migrated server's source appears.
113 candidate: ArcSwap<Option<SocketAddr>>,
114 /// Anti-amplification budget for the candidate (D9, RFC 9000 §8.2): bytes received from /
115 /// sent to the candidate, so a `PATH_CHALLENGE` to a possibly-spoofed new server source
116 /// never exceeds 3× what it sent us. Bounds the redirection/reflection an on-path
117 /// attacker (replaying a fresh frame with a spoofed source) could induce.
118 cand_recv: AtomicU64,
119 cand_sent: AtomicU64,
120 /// Source + byte length of the most recent `recv_bytes` frame (M-1). The candidate is
121 /// committed from this ONLY by `confirm_authenticated_source` on the post-decrypt path,
122 /// so a spoofed / replayed datagram (which fails AEAD or the replay window before
123 /// `confirm_authenticated_source` runs) can never clobber the candidate slot.
124 last_recv_src: ArcSwap<Option<SocketAddr>>,
125 last_frame_len: AtomicU64,
126}
127
128impl UdpClientTransport {
129 /// Bind a fresh UNCONNECTED UDP socket for talking to `server`, choosing a random
130 /// lifetime connection-ID. The socket is left unconnected (no `socket.connect`) so the
131 /// client can `recv_from` any source — the precondition for hearing, and later
132 /// following, a server that migrates to a new address. Datagrams are sent with
133 /// `send_to(server_addr)`.
134 pub async fn connect(server: SocketAddr) -> Result<Self, CoreError> {
135 let bind = if server.is_ipv4() {
136 "0.0.0.0:0"
137 } else {
138 "[::]:0"
139 };
140 let socket = UdpSocket::bind(bind)
141 .await
142 .map_err(|e| CoreError::NetworkError(format!("udp bind: {e}")))?;
143 let mut cid = [0u8; 8];
144 getrandom::getrandom(&mut cid).map_err(|e| CoreError::RngError(e.to_string()))?;
145 Ok(Self {
146 socket: ArcSwap::from_pointee(socket),
147 prev_socket: ArcSwap::from_pointee(None),
148 server_addr: ArcSwap::from_pointee(server),
149 cid,
150 established_cid: ArcSwap::from_pointee(None),
151 phase: AtomicU8::new(PHASE_HANDSHAKE),
152 next_packet_id: AtomicU32::new(0),
153 last_sent: Mutex::new(Vec::new()),
154 reasm: Mutex::new(FragmentAssembler::new()),
155 candidate: ArcSwap::from_pointee(None),
156 cand_recv: AtomicU64::new(0),
157 cand_sent: AtomicU64::new(0),
158 last_recv_src: ArcSwap::from_pointee(None),
159 last_frame_len: AtomicU64::new(0),
160 })
161 }
162
163 /// Rebind to a fresh local socket and route subsequent traffic through it
164 /// (Phase 4 / P4.2b — embedder-triggered migration). Best-effort and
165 /// non-blocking on validation: it binds a fresh unconnected socket (sends still
166 /// `send_to` the tracked `server_addr`), then atomically swaps it in as the active
167 /// socket while KEEPING the old one for the overlap (the recv loop listens on both
168 /// until the new path shows
169 /// life, then drops the old — broken-rebind safety / D7). Subsequent app data +
170 /// L1 retransmits go out the new socket, so the server detects the new source
171 /// (P4.1) and challenges → validates → swaps its peer. The caller is responsible
172 /// for bumping the session send `path_id` ([`Session::next_migration_path_id`]).
173 ///
174 /// A bind/connect failure returns `Err` WITHOUT touching the active socket, so a
175 /// migration to a dead/invalid address never tears the session down — the session
176 /// keeps running on the old socket.
177 ///
178 /// Typed core of the migration; the SocketAddr-free [`SessionTransport::migrate`]
179 /// trait entry parses a `String` and delegates here.
180 ///
181 /// [`Session::next_migration_path_id`]: crate::transport::session::Session::next_migration_path_id
182 pub async fn migrate_to(&self, new_local_addr: SocketAddr) -> Result<(), CoreError> {
183 let new_sock = UdpSocket::bind(new_local_addr)
184 .await
185 .map_err(|e| CoreError::NetworkError(format!("udp migrate bind: {e}")))?;
186 // The socket stays UNCONNECTED (sends use `send_to(server_addr)`), so there is no
187 // `connect` step — the send target is the tracked `server_addr`, unchanged by a
188 // local rebind.
189 let new_sock = Arc::new(new_sock);
190 // Retain the old socket FIRST (so the recv loop never sees a gap), then swap
191 // the active socket. The recv loop drops the retained socket on the first
192 // frame it receives on the new one.
193 let old = self.socket.load_full();
194 self.prev_socket.store(Arc::new(Some(old)));
195 self.socket.store(new_sock);
196 Ok(())
197 }
198
199 /// The connection-ID this client stamps on every datagram (test/inspection helper).
200 // Only called from the `#[cfg(test)]` module; `--lib` clippy excludes test code, so the
201 // dead-code lint would fire without this allow.
202 #[allow(dead_code)]
203 pub(crate) fn cid(&self) -> ConnId {
204 self.cid
205 }
206
207 /// Whether a post-migration dual-socket overlap is currently active (test/inspection
208 /// helper): `true` between a `migrate_to` and the first well-formed datagram on the new
209 /// socket that retires the old one.
210 #[cfg(test)]
211 pub(crate) fn in_migration_overlap(&self) -> bool {
212 self.prev_socket.load().is_some()
213 }
214
215 fn pkt_id(&self) -> u32 {
216 self.next_packet_id.fetch_add(1, Ordering::Relaxed)
217 }
218}
219
220impl SessionTransport for UdpClientTransport {
221 async fn send_bytes(&self, data: &[u8]) -> Result<(), CoreError> {
222 // Handshake frames are Initial (long header); post-handshake frames are OneRtt (short header).
223 let ty = if self.phase.load(Ordering::Relaxed) == PHASE_HANDSHAKE {
224 PacketType::Initial
225 } else {
226 PacketType::OneRtt
227 };
228 // ε / WIRE v5: stamp the rotating CID once the handshake set it; the
229 // bootstrap `cid` is stamped until then.
230 let cid = (**self.established_cid.load()).unwrap_or(self.cid);
231 let dgrams = encode_datagrams(ty, &cid, self.pkt_id(), data)
232 .map_err(|e| CoreError::NetworkError(format!("frame too large to fragment: {e}")))?;
233 // Snapshot the active socket (owned `Arc`, not a `Guard`) so we never hold an
234 // `ArcSwap` guard across `.await` — `migrate()` can swap it concurrently.
235 let sock = self.socket.load_full();
236 // Unconnected socket: the destination is explicit. `server_addr` is the tracked
237 // server remote (a later server-migration follow can re-point it without rebinding).
238 let dst = **self.server_addr.load();
239 for d in &dgrams {
240 sock.send_to(d, dst)
241 .await
242 .map_err(|e| CoreError::NetworkError(format!("udp send: {e}")))?;
243 }
244 // Remember for Handshake-phase retransmit (ignored once Established).
245 *self.last_sent.lock().await = dgrams;
246 Ok(())
247 }
248
249 async fn recv_bytes(&self) -> Result<Bytes, CoreError> {
250 // Sized at PATH_MTU + slack. We only ever emit datagrams <= PATH_MTU, so a legitimate peer
251 // never exceeds this; an oversized datagram is truncated by `recv_from` and then dropped by
252 // the `decode_header`/reassembly failure path below — intentional.
253 let mut buf = vec![0u8; PATH_MTU + 64];
254 // Second recv buffer, lazily sized only during a migration overlap; the common
255 // no-migration path keeps the single `buf`.
256 let mut buf_prev: Vec<u8> = Vec::new();
257 let mut retx = 0u32;
258 loop {
259 let in_handshake = self.phase.load(Ordering::Relaxed) == PHASE_HANDSHAKE;
260 // Snapshot both sockets as owned `Arc`s (never hold an `ArcSwap` guard
261 // across `.await`). `prev_opt` is `Some` only during a post-handshake
262 // migration overlap; we then listen on BOTH the new (active) socket and the
263 // retained old one (D7 / broken-rebind safety) until the new path shows
264 // life, then drop the old.
265 let active = self.socket.load_full();
266 let prev_arc = self.prev_socket.load_full();
267 let prev_opt: Option<Arc<UdpSocket>> = (*prev_arc).clone();
268 // The socket is unconnected, so `recv_from` returns the source of every datagram;
269 // we deliver from ANY source (the inner AEAD + replay window are the real guards,
270 // exactly as the server delivers any CID-matched datagram via its demux). During a
271 // migration overlap, ANY well-formed datagram on the NEW socket means the path is
272 // up — the overlap-drop after `push_datagram` (below) retires the old socket then.
273
274 // `from_prev` records which buffer the datagram landed in, so we slice the
275 // right one AFTER the select — the recv future's `&mut buf` borrow is
276 // released when its arm wins, exactly as the single-socket path relied on.
277 let (n, from_prev, src): (usize, bool, SocketAddr) = if in_handshake {
278 // Migration is post-handshake only, so there is never a `prev` socket
279 // here; keep the original single-socket + RTO-retransmit logic.
280 let server = **self.server_addr.load();
281 tokio::select! {
282 // `biased;` polls the recv arm first: the RTO must be a true
283 // "no data arrived for HANDSHAKE_RTO" timer, not a coin-flip against an
284 // already-queued datagram. With the default unbiased select, when BOTH a datagram
285 // is ready AND the sleep has elapsed (common under contention from concurrent PQ
286 // handshakes), the recv arm is starved ~50% of the time, so the client spuriously
287 // retransmits instead of processing the already-arrived ServerHello — exhausting
288 // MAX_HANDSHAKE_RETX and timing the handshake out. Biasing toward received data
289 // makes the RTO fire only when recv is genuinely pending.
290 biased;
291 r = active.recv_from(&mut buf) => match classify_recv(r) {
292 RecvAction::Got(n, src) => (n, false, src),
293 RecvAction::Retry => {
294 log::debug!("PhantomUDP: advisory recv error (ignored, RFC 8085 §5.5)");
295 continue;
296 }
297 RecvAction::Fatal(e) => {
298 return Err(CoreError::NetworkError(format!("udp recv: {e}")))
299 }
300 },
301 _ = tokio::time::sleep(HANDSHAKE_RTO) => {
302 retx += 1;
303 if retx > MAX_HANDSHAKE_RETX {
304 return Err(CoreError::Timeout);
305 }
306 for d in self.last_sent.lock().await.iter() {
307 let _ = active.send_to(d, server).await;
308 }
309 continue;
310 }
311 }
312 } else if let Some(prev_sock) = &prev_opt {
313 if buf_prev.len() < PATH_MTU + 64 {
314 buf_prev.resize(PATH_MTU + 64, 0);
315 }
316 tokio::select! {
317 r = active.recv_from(&mut buf) => match classify_recv(r) {
318 RecvAction::Got(n, src) => (n, false, src),
319 RecvAction::Retry => {
320 log::debug!("PhantomUDP: advisory recv error on new path (ignored)");
321 continue;
322 }
323 RecvAction::Fatal(e) => {
324 return Err(CoreError::NetworkError(format!("udp recv: {e}")))
325 }
326 },
327 r = prev_sock.recv_from(&mut buf_prev) => match classify_recv(r) {
328 RecvAction::Got(n, src) => (n, true, src),
329 RecvAction::Retry => {
330 log::debug!("PhantomUDP: advisory recv error on old path (ignored)");
331 continue;
332 }
333 RecvAction::Fatal(e) => {
334 return Err(CoreError::NetworkError(format!("udp recv: {e}")))
335 }
336 },
337 }
338 } else {
339 match classify_recv(active.recv_from(&mut buf).await) {
340 RecvAction::Got(n, src) => (n, false, src),
341 RecvAction::Retry => {
342 log::debug!("PhantomUDP: advisory recv error (ignored, RFC 8085 §5.5)");
343 continue;
344 }
345 RecvAction::Fatal(e) => {
346 return Err(CoreError::NetworkError(format!("udp recv: {e}")))
347 }
348 }
349 };
350 retx = 0; // progress: reset the RTO budget
351 let datagram = if from_prev { &buf_prev[..n] } else { &buf[..n] };
352 let mut asm = self.reasm.lock().await;
353 let decoded = push_datagram(&mut asm, datagram);
354 // Overlap-drop (D7): a well-formed datagram on the NEW (active) socket means the
355 // path is up, so retire the retained old socket — regardless of source, so it
356 // works whether the server is reaching us at the established address OR has
357 // itself migrated to a new one (a `src == server_addr` check would never fire in
358 // the latter case and strand the overlap). Garbage spray (a decode `Err`) does
359 // NOT end the overlap; data on the OLD socket (`from_prev`) never does either.
360 if !from_prev && prev_opt.is_some() && decoded.is_ok() {
361 self.prev_socket.store(Arc::new(None));
362 }
363 match decoded {
364 Ok((_hdr, Some(frame))) => {
365 // M-1 (server-migration candidate detection): record the source + frame
366 // length of this still-AEAD-pending frame. The candidate is committed ONLY
367 // by the post-decrypt `confirm_authenticated_source`, so a spoofed / replayed
368 // datagram (rejected by AEAD or the replay window before that runs) can never
369 // clobber the candidate slot. Mirrors `UdpServerTransport::recv_bytes`.
370 self.last_recv_src.store(Arc::new(Some(src)));
371 self.last_frame_len
372 .store(frame.len() as u64, Ordering::Relaxed);
373 return Ok(Bytes::from(frame));
374 }
375 Ok((_hdr, None)) => continue, // partial fragment; keep receiving
376 Err(_) => continue, // malformed datagram; drop and keep receiving
377 }
378 }
379 }
380
381 fn set_frame_phase(&self, phase: FramePhase) {
382 let v = match phase {
383 FramePhase::Handshake => PHASE_HANDSHAKE,
384 FramePhase::Established => PHASE_ESTABLISHED,
385 };
386 self.phase.store(v, Ordering::Relaxed);
387 }
388
389 fn set_outbound_cid(&self, cid: [u8; 8]) {
390 self.established_cid.store(Arc::new(Some(cid)));
391 }
392
393 /// SocketAddr-free trait entry for connection migration (Phase 4 / P4.2c). Parses
394 /// the embedder-supplied local bind address and delegates to the typed
395 /// [`migrate_to`](Self::migrate_to). A malformed address is a clean `Err` that
396 /// leaves the session on its existing socket (best-effort, never fatal).
397 async fn migrate(&self, local_addr: String) -> Result<(), CoreError> {
398 let addr: SocketAddr = local_addr.parse().map_err(|e| {
399 CoreError::NetworkError(format!("migrate: bad local addr '{local_addr}': {e}"))
400 })?;
401 self.migrate_to(addr).await
402 }
403
404 // ── Server-migration follow (the client side of A2a) ──────────────────────
405 //
406 // The exact mirror of `UdpServerTransport`'s client-migration candidate machinery,
407 // with the roles flipped: here the candidate is a NEW SERVER source, and a promotion
408 // re-points `server_addr` (the c2s send target). The shared recv-path
409 // (`handle_packet`) drives these the same way for both peers — it commits the
410 // candidate post-AEAD (`confirm_authenticated_source`), challenges it under the 3×
411 // anti-amplification cap (`send_to_candidate`), and promotes it on a valid
412 // PATH_RESPONSE (`promote_candidate`). Anti-spoof holds exactly as on the server: a
413 // spoofed / replayed datagram is rejected by AEAD or the replay window BEFORE
414 // `confirm_authenticated_source` runs, so it can never become the candidate; and the
415 // switch happens only after the candidate echoes a path-validation challenge, so an
416 // on-path attacker that rewrites a fresh frame's source to a victim can at most induce
417 // a bounded (≤3×) PATH_CHALLENGE to that victim, never a c2s redirection.
418
419 fn confirm_authenticated_source(&self) {
420 // M-1: the frame from `last_recv_src` just authenticated (AEAD-opened, non-replayed),
421 // so it really is the server — possibly at a NEW address (the server migrated). Commit
422 // it as the candidate the session path-validates before switching, and (re)seed its
423 // anti-amplification budget. A spoofed source never reaches here.
424 let src = match **self.last_recv_src.load() {
425 Some(s) => s,
426 None => return,
427 };
428 if src == **self.server_addr.load() {
429 return; // already the established server — not a new path
430 }
431 let len = self.last_frame_len.load(Ordering::Relaxed);
432 if self.candidate.load().as_ref() == &Some(src) {
433 self.cand_recv.fetch_add(len, Ordering::Relaxed);
434 } else {
435 self.candidate.store(Arc::new(Some(src)));
436 self.cand_recv.store(len, Ordering::Relaxed);
437 self.cand_sent.store(0, Ordering::Relaxed);
438 }
439 }
440
441 fn has_migration_candidate(&self) -> bool {
442 self.candidate.load().is_some()
443 }
444
445 async fn send_to_candidate(&self, data: &[u8]) -> Result<bool, CoreError> {
446 let cand = self.candidate.load();
447 let addr = match cand.as_ref() {
448 Some(a) => *a,
449 None => return Ok(false),
450 };
451 let pid = self.next_packet_id.fetch_add(1, Ordering::Relaxed);
452 // Bootstrap cid (not the rotating `established_cid`): a challenge to a possibly-spoofed
453 // address must not leak the keyed rotating CID, mirroring the server.
454 let dgrams = encode_datagrams(PacketType::OneRtt, &self.cid, pid, data)
455 .map_err(|e| CoreError::NetworkError(format!("challenge too large: {e}")))?;
456 let wire: u64 = dgrams.iter().map(|d| d.len() as u64).sum();
457 // Anti-amplification (D9, RFC 9000 §8.2): never send > 3× what the candidate sent us.
458 let recv = self.cand_recv.load(Ordering::Relaxed);
459 if self.cand_sent.load(Ordering::Relaxed).saturating_add(wire) > recv.saturating_mul(3) {
460 return Ok(false);
461 }
462 let sock = self.socket.load_full();
463 for d in &dgrams {
464 sock.send_to(d, addr)
465 .await
466 .map_err(|e| CoreError::NetworkError(format!("udp send_to candidate: {e}")))?;
467 }
468 self.cand_sent.fetch_add(wire, Ordering::Relaxed);
469 Ok(true)
470 }
471
472 fn promote_candidate(&self) -> bool {
473 let cand = self.candidate.load();
474 match cand.as_ref() {
475 Some(addr) => {
476 // The candidate's path validated: re-point the c2s send target to the new
477 // server address + clear the candidate / anti-amp budget. Subsequent
478 // `send_bytes` (and L1 retransmits) now flow to the migrated server.
479 self.server_addr.store(Arc::new(*addr));
480 self.candidate.store(Arc::new(None));
481 self.cand_recv.store(0, Ordering::Relaxed);
482 self.cand_sent.store(0, Ordering::Relaxed);
483 true
484 }
485 None => false,
486 }
487 }
488}
489
490/// Per-session server transport. The listener's demux task reassembles inbound datagrams and pushes
491/// the inner frames to `rx`; outbound frames are enveloped and sent to the captured `peer` from
492/// `send_socket`. A server migration ([`migrate_to`](Self::migrate_to)) swaps `send_socket` to a
493/// freshly-bound local socket (so the client sees a new s2c source) and spawns a recv loop on it
494/// that feeds the SAME `rx` channel via `tx`, so the client's c2s frames are delivered transparently
495/// once it switches its send target — while the listener demux keeps feeding `rx` on the old address
496/// during the overlap.
497pub struct UdpServerTransport {
498 /// Active send socket. Starts as the shared listener socket (so all sessions egress
499 /// the listen address) and is atomically swapped to a freshly-bound dedicated socket on
500 /// a server migration (`migrate_to`), changing this session's s2c source address.
501 send_socket: ArcSwap<UdpSocket>,
502 /// Sender half of this session's inbound channel (the demux holds a sibling clone for
503 /// the listener-routed path). A server migration's recv loop forwards frames it receives
504 /// on the new socket through this, so `recv_bytes` (reading `rx`) sees both the
505 /// listener-demuxed old path and the migrated new path during the overlap.
506 tx: mpsc::Sender<(Bytes, SocketAddr)>,
507 /// Handle to the current server-migration recv loop, if any. Aborted when a newer
508 /// migration replaces it (a stale socket from an earlier move) and on drop, so the
509 /// task and its socket do not leak across repeated migrations.
510 migrate_task: parking_lot::Mutex<Option<tokio::task::JoinHandle<()>>>,
511 /// Established peer. `ArcSwap` so the session can atomically switch it to a
512 /// validated migration candidate (Phase 4 / P4.2) without re-handshake.
513 peer: ArcSwap<SocketAddr>,
514 /// The bootstrap (handshake) ConnId, stamped until the session sets the
515 /// rotating chain via [`set_outbound_cid`](SessionTransport::set_outbound_cid).
516 cid: ConnId,
517 /// The rotating routing CID set at the handshake → data-pump boundary (ε /
518 /// WIRE v5). `None` during the handshake; `Some(CID_0)` once the session sets
519 /// it. Stamped on server→client datagrams so that direction also rotates.
520 established_cid: ArcSwap<Option<ConnId>>,
521 phase: AtomicU8,
522 next_packet_id: AtomicU32,
523 rx: Mutex<mpsc::Receiver<(Bytes, SocketAddr)>>,
524 /// Migration candidate (Phase 4, P4.1): a source address other than `peer`
525 /// observed for this CID. The session challenges it before any switch; the
526 /// switch itself (changing `peer`) is P4.2. `None` until a new source appears.
527 candidate: ArcSwap<Option<SocketAddr>>,
528 /// Anti-amplification budget for the candidate (D9, RFC 9000 §8.2): bytes
529 /// received from / sent to the candidate, so a challenge to a possibly-spoofed
530 /// address never exceeds 3× what it sent us.
531 cand_recv: AtomicU64,
532 cand_sent: AtomicU64,
533 /// Source + byte length of the most recent `recv_bytes` frame (M-1). The candidate is
534 /// committed from this ONLY by `confirm_authenticated_source` on the post-decrypt path, so
535 /// a spoofed (never-decrypting) datagram cannot clobber the candidate slot.
536 last_recv_src: ArcSwap<Option<SocketAddr>>,
537 last_frame_len: AtomicU64,
538}
539
540impl UdpServerTransport {
541 pub fn new(
542 socket: Arc<UdpSocket>,
543 peer: SocketAddr,
544 cid: ConnId,
545 tx: mpsc::Sender<(Bytes, SocketAddr)>,
546 rx: mpsc::Receiver<(Bytes, SocketAddr)>,
547 ) -> Self {
548 Self {
549 send_socket: ArcSwap::new(socket),
550 tx,
551 migrate_task: parking_lot::Mutex::new(None),
552 peer: ArcSwap::from_pointee(peer),
553 cid,
554 established_cid: ArcSwap::from_pointee(None),
555 phase: AtomicU8::new(PHASE_HANDSHAKE),
556 next_packet_id: AtomicU32::new(0),
557 rx: Mutex::new(rx),
558 candidate: ArcSwap::from_pointee(None),
559 cand_recv: AtomicU64::new(0),
560 cand_sent: AtomicU64::new(0),
561 last_recv_src: ArcSwap::from_pointee(None),
562 last_frame_len: AtomicU64::new(0),
563 }
564 }
565
566 /// Migrate the server's send path to a fresh local socket (the server-side mirror of
567 /// the client's [`UdpClientTransport::migrate_to`]). Binds a new unconnected socket,
568 /// swaps it in as the active send socket (so subsequent server→client datagrams egress
569 /// the new local address — the client sees a new s2c source and follows it), and spawns
570 /// a recv loop on it that reassembles datagrams and forwards complete frames into the
571 /// SAME inbound channel `recv_bytes` reads. The listener demux keeps feeding that channel
572 /// on the old (listen) address during the overlap, so c2s never drops; once the client
573 /// switches its send target to the new address (path validation, a later step), its
574 /// frames arrive on the new socket and flow through transparently.
575 ///
576 /// A bind failure returns `Err` WITHOUT touching the active send socket — a server
577 /// migration to an invalid address never tears the session down (best-effort, like the
578 /// client side). Typed core; the `SocketAddr`-free [`SessionTransport::migrate_server`]
579 /// trait entry parses a `String` and delegates here.
580 pub async fn migrate_to(&self, new_local_addr: SocketAddr) -> Result<(), CoreError> {
581 let new_sock = UdpSocket::bind(new_local_addr)
582 .await
583 .map_err(|e| CoreError::NetworkError(format!("udp server migrate bind: {e}")))?;
584 let new_sock = Arc::new(new_sock);
585 // The recv loop and the send path share the same socket: the client sends c2s to
586 // the new server address (where the loop listens) and the server's s2c egress that
587 // same socket, so the client's source filter sees a single new server source.
588 let recv_sock = new_sock.clone();
589 let tx = self.tx.clone();
590 let task = tokio::spawn(async move {
591 Self::run_migration_recv(recv_sock, tx).await;
592 });
593 // Abort any prior migration recv loop (a stale socket from an earlier move) before
594 // publishing the new one, so tasks/sockets do not accumulate across migrations.
595 if let Some(old) = self.migrate_task.lock().replace(task) {
596 old.abort();
597 }
598 self.send_socket.store(new_sock);
599 Ok(())
600 }
601
602 /// Recv loop for a migrated server send socket: reassemble datagrams and forward complete
603 /// frames into the session's inbound channel (tagged with the source, M-1), exactly as the
604 /// listener demux does for the old path. Exits when the channel is closed (session gone)
605 /// or on a fatal socket error; an advisory ICMP error is retried.
606 async fn run_migration_recv(sock: Arc<UdpSocket>, tx: mpsc::Sender<(Bytes, SocketAddr)>) {
607 let mut asm = FragmentAssembler::new();
608 let mut buf = vec![0u8; PATH_MTU + 64];
609 loop {
610 let (n, src) = match sock.recv_from(&mut buf).await {
611 Ok(v) => v,
612 Err(e) if is_advisory_recv_error(&e) => continue,
613 Err(_) => return, // fatal socket error — stop this migration recv loop
614 };
615 match push_datagram(&mut asm, &buf[..n]) {
616 Ok((_hdr, Some(frame))) => {
617 // A full channel drops the datagram (the peer retransmits); a closed
618 // channel means the session ended → stop the loop.
619 if tx.try_send((Bytes::from(frame), src)).is_err() && tx.is_closed() {
620 return;
621 }
622 }
623 Ok((_hdr, None)) => {} // partial fragment buffered
624 Err(_) => {} // malformed datagram — drop and keep receiving
625 }
626 }
627 }
628}
629
630impl Drop for UdpServerTransport {
631 fn drop(&mut self) {
632 // Stop any in-flight server-migration recv loop so its task + socket are reclaimed
633 // even if no further datagram ever arrives to trip the channel-closed check.
634 if let Some(h) = self.migrate_task.get_mut().take() {
635 h.abort();
636 }
637 }
638}
639
640impl SessionTransport for UdpServerTransport {
641 async fn send_bytes(&self, data: &[u8]) -> Result<(), CoreError> {
642 let ty = if self.phase.load(Ordering::Relaxed) == PHASE_HANDSHAKE {
643 PacketType::Initial
644 } else {
645 PacketType::OneRtt
646 };
647 let pid = self.next_packet_id.fetch_add(1, Ordering::Relaxed);
648 // ε / WIRE v5: stamp the rotating CID once the handshake set it.
649 let cid = (**self.established_cid.load()).unwrap_or(self.cid);
650 let dgrams = encode_datagrams(ty, &cid, pid, data)
651 .map_err(|e| CoreError::NetworkError(format!("frame too large to fragment: {e}")))?;
652 let peer = **self.peer.load();
653 // Snapshot the active send socket (owned `Arc`) so we never hold an `ArcSwap` guard
654 // across `.await` — a server `migrate_to` can swap it concurrently.
655 let sock = self.send_socket.load_full();
656 for d in &dgrams {
657 sock.send_to(d, peer)
658 .await
659 .map_err(|e| CoreError::NetworkError(format!("udp send_to: {e}")))?;
660 }
661 Ok(())
662 }
663
664 async fn recv_bytes(&self) -> Result<Bytes, CoreError> {
665 let (frame, src) = self
666 .rx
667 .lock()
668 .await
669 .recv()
670 .await
671 .ok_or(CoreError::ConnectionClosed)?;
672 // M-1: record the source + length but do NOT register a migration candidate here — this
673 // frame has not been AEAD-verified yet, and a spoofed CID-matched datagram looks
674 // identical at this point. The candidate is committed only from the post-decrypt
675 // `confirm_authenticated_source`, so a spoofed source cannot clobber the candidate slot
676 // and misdirect / stall a legitimate migration (Phase 4, P4.1 — detect-only, no switch).
677 self.last_recv_src.store(Arc::new(Some(src)));
678 self.last_frame_len
679 .store(frame.len() as u64, Ordering::Relaxed);
680 Ok(frame)
681 }
682
683 fn confirm_authenticated_source(&self) {
684 // M-1: the frame from `last_recv_src` just authenticated (AEAD-opened), so it really is
685 // the established peer — possibly at a NEW address (migration / NAT rebind). Register it
686 // as the candidate the session challenges before switching, and (re)seed its
687 // anti-amplification budget. A spoofed source never reaches here (its frame fails
688 // decrypt), so it can never become the candidate.
689 let src = match **self.last_recv_src.load() {
690 Some(s) => s,
691 None => return,
692 };
693 if src == **self.peer.load() {
694 return; // already the established peer — not a new path
695 }
696 let len = self.last_frame_len.load(Ordering::Relaxed);
697 if self.candidate.load().as_ref() == &Some(src) {
698 self.cand_recv.fetch_add(len, Ordering::Relaxed);
699 } else {
700 self.candidate.store(Arc::new(Some(src)));
701 self.cand_recv.store(len, Ordering::Relaxed);
702 self.cand_sent.store(0, Ordering::Relaxed);
703 }
704 }
705
706 fn has_migration_candidate(&self) -> bool {
707 self.candidate.load().is_some()
708 }
709
710 async fn send_to_candidate(&self, data: &[u8]) -> Result<bool, CoreError> {
711 let cand = self.candidate.load();
712 let addr = match cand.as_ref() {
713 Some(a) => *a,
714 None => return Ok(false),
715 };
716 let pid = self.next_packet_id.fetch_add(1, Ordering::Relaxed);
717 let dgrams = encode_datagrams(PacketType::OneRtt, &self.cid, pid, data)
718 .map_err(|e| CoreError::NetworkError(format!("challenge too large: {e}")))?;
719 let wire: u64 = dgrams.iter().map(|d| d.len() as u64).sum();
720 // Anti-amplification (D9, RFC 9000 §8.2): never send > 3× what the
721 // candidate sent us. Drop the challenge rather than become a reflector.
722 let recv = self.cand_recv.load(Ordering::Relaxed);
723 if self.cand_sent.load(Ordering::Relaxed).saturating_add(wire) > recv.saturating_mul(3) {
724 return Ok(false);
725 }
726 let sock = self.send_socket.load_full();
727 for d in &dgrams {
728 sock.send_to(d, addr)
729 .await
730 .map_err(|e| CoreError::NetworkError(format!("udp send_to candidate: {e}")))?;
731 }
732 self.cand_sent.fetch_add(wire, Ordering::Relaxed);
733 Ok(true)
734 }
735
736 fn promote_candidate(&self) -> bool {
737 let cand = self.candidate.load();
738 match cand.as_ref() {
739 Some(addr) => {
740 // Switch the active peer to the validated candidate; clear the
741 // candidate + its anti-amp budget. Subsequent send_bytes + ARQ
742 // retransmits now target the new address.
743 self.peer.store(Arc::new(*addr));
744 self.candidate.store(Arc::new(None));
745 self.cand_recv.store(0, Ordering::Relaxed);
746 self.cand_sent.store(0, Ordering::Relaxed);
747 true
748 }
749 None => false,
750 }
751 }
752
753 fn set_frame_phase(&self, phase: FramePhase) {
754 let v = match phase {
755 FramePhase::Handshake => PHASE_HANDSHAKE,
756 FramePhase::Established => PHASE_ESTABLISHED,
757 };
758 self.phase.store(v, Ordering::Relaxed);
759 }
760
761 fn set_outbound_cid(&self, cid: [u8; 8]) {
762 self.established_cid.store(Arc::new(Some(cid)));
763 }
764
765 /// SocketAddr-free trait entry for server-side migration. Parses the new local bind
766 /// address and delegates to the typed [`migrate_to`](Self::migrate_to). A malformed
767 /// address is a clean `Err` that leaves the session on its existing send socket
768 /// (best-effort, never fatal).
769 async fn migrate_server(&self, local_addr: String) -> Result<(), CoreError> {
770 let addr: SocketAddr = local_addr.parse().map_err(|e| {
771 CoreError::NetworkError(format!(
772 "migrate_server: bad local addr '{local_addr}': {e}"
773 ))
774 })?;
775 self.migrate_to(addr).await
776 }
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782 use crate::transport::phantom_udp::datagram::{push_datagram, FragmentAssembler};
783 use crate::transport::phantom_udp::envelope::PacketType;
784 use tokio::net::UdpSocket;
785
786 /// A framed frame round-trips client -> raw peer -> client, including a >MTU
787 /// (fragmented) reply that `recv_bytes` reassembles.
788 #[tokio::test]
789 async fn client_send_recv_with_fragmented_reply() {
790 let peer = UdpSocket::bind("127.0.0.1:0").await.unwrap();
791 let peer_addr = peer.local_addr().unwrap();
792 let client = UdpClientTransport::connect(peer_addr).await.unwrap();
793
794 // Client sends a small frame.
795 client.send_bytes(b"hello").await.unwrap();
796 let mut buf = vec![0u8; 2048];
797 let (n, from) = peer.recv_from(&mut buf).await.unwrap();
798 let mut asm = FragmentAssembler::new();
799 let (_h, got) = push_datagram(&mut asm, &buf[..n]).unwrap();
800 assert_eq!(got.as_deref(), Some(&b"hello"[..]));
801
802 // Peer replies with a >MTU frame (fragments); client reassembles via recv_bytes.
803 let big: Vec<u8> = (0..5000u32).map(|i| i as u8).collect();
804 for d in encode_datagrams(PacketType::OneRtt, &client.cid(), 1, &big).expect("encode") {
805 peer.send_to(&d, from).await.unwrap();
806 }
807 let recv = tokio::time::timeout(std::time::Duration::from_secs(2), client.recv_bytes())
808 .await
809 .expect("no timeout")
810 .expect("recv");
811 assert_eq!(&recv[..], &big[..]);
812 }
813
814 /// D1 (server migration): the client uses an UNCONNECTED socket, so it can hear a
815 /// datagram from a source *other* than its original connect target — the precondition
816 /// for following a server that has migrated its send address to a new socket. A
817 /// connected socket drops such a datagram at the kernel (wrong source); the unconnected
818 /// client must deliver it (the inner AEAD + replay window are the real guards).
819 #[tokio::test]
820 async fn client_hears_a_datagram_from_a_new_server_source() {
821 let server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
822 let server_addr = server.local_addr().unwrap();
823 let client = UdpClientTransport::connect(server_addr).await.unwrap();
824 client.set_frame_phase(FramePhase::Established);
825
826 // The server learns the client's local address (so the new source can target it).
827 client.send_bytes(b"hello").await.unwrap();
828 let mut buf = vec![0u8; 2048];
829 let (_n, client_addr) = server.recv_from(&mut buf).await.unwrap();
830
831 // A DIFFERENT source (a migrated server's freshly-bound socket) sends a framed
832 // datagram to the client. On a connected socket the kernel drops it; the
833 // unconnected client must deliver it up through `recv_bytes`.
834 let migrated = UdpSocket::bind("127.0.0.1:0").await.unwrap();
835 for d in encode_datagrams(PacketType::OneRtt, &client.cid(), 1, b"from-new-source").unwrap()
836 {
837 migrated.send_to(&d, client_addr).await.unwrap();
838 }
839 let got = tokio::time::timeout(Duration::from_secs(2), client.recv_bytes())
840 .await
841 .expect("client must hear a new server source (unconnected socket)")
842 .expect("recv");
843 assert_eq!(&got[..], b"from-new-source");
844 }
845
846 /// While in Handshake phase, a dropped first datagram is retransmitted on RTO.
847 #[tokio::test]
848 async fn client_retransmits_handshake_on_rto() {
849 let peer = UdpSocket::bind("127.0.0.1:0").await.unwrap();
850 let peer_addr = peer.local_addr().unwrap();
851 let client = UdpClientTransport::connect(peer_addr).await.unwrap();
852 // default phase is Handshake.
853 let send = async {
854 client.send_bytes(b"flight1").await.unwrap();
855 };
856 let mut buf = vec![0u8; 2048];
857 // Peer ignores the first datagram, reads the retransmit.
858 let recv = async {
859 let _ = peer.recv_from(&mut buf).await.unwrap(); // drop #1
860 let (n, from) = peer.recv_from(&mut buf).await.unwrap(); // retransmit
861 // Reply so client's recv_bytes completes.
862 for d in
863 encode_datagrams(PacketType::Initial, &client.cid(), 0, b"reply").expect("encode")
864 {
865 peer.send_to(&d, from).await.unwrap();
866 }
867 n
868 };
869 let recv_client = async {
870 tokio::time::timeout(std::time::Duration::from_secs(3), client.recv_bytes()).await
871 };
872 let (_s, n, r) = tokio::join!(send, recv, recv_client);
873 assert!(n >= super::HDR_LEN);
874 assert_eq!(&r.unwrap().unwrap()[..], &b"reply"[..]);
875 }
876
877 #[tokio::test]
878 async fn server_transport_send_and_recv() {
879 use tokio::sync::mpsc;
880 let sock = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
881 let peer = UdpSocket::bind("127.0.0.1:0").await.unwrap();
882 let peer_addr = peer.local_addr().unwrap();
883 let (tx, rx) = mpsc::channel(8);
884 let st = UdpServerTransport::new(sock.clone(), peer_addr, [3u8; 8], tx.clone(), rx);
885
886 // recv_bytes returns frames pushed to the channel (as the demux would),
887 // tagged with the source address (here the established peer).
888 tx.send((Bytes::from_static(b"from-demux"), peer_addr))
889 .await
890 .unwrap();
891 assert_eq!(&st.recv_bytes().await.unwrap()[..], b"from-demux");
892
893 // send_bytes writes an enveloped datagram the raw peer can decode.
894 st.set_frame_phase(FramePhase::Established);
895 st.send_bytes(b"to-peer").await.unwrap();
896 let mut buf = vec![0u8; 2048];
897 let (n, _from) = peer.recv_from(&mut buf).await.unwrap();
898 let mut asm = FragmentAssembler::new();
899 let (hdr, got) = push_datagram(&mut asm, &buf[..n]).unwrap();
900 assert_eq!(hdr.cid, [3u8; 8]);
901 assert_eq!(got.as_deref(), Some(&b"to-peer"[..]));
902 }
903
904 /// P4.1: a frame from a source other than the established peer registers a
905 /// migration candidate; `send_to_candidate` reaches it under the 3×
906 /// anti-amplification cap (D9). No peer switch happens here (that is P4.2).
907 #[tokio::test]
908 async fn server_detects_candidate_and_caps_amplification() {
909 use tokio::sync::mpsc;
910 let sock = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
911 let peer = UdpSocket::bind("127.0.0.1:0")
912 .await
913 .unwrap()
914 .local_addr()
915 .unwrap();
916 let (tx, rx) = mpsc::channel(16);
917 let st = UdpServerTransport::new(sock.clone(), peer, [9u8; 8], tx.clone(), rx);
918
919 // The established peer is not a candidate, and there is nothing to send to.
920 tx.send((Bytes::from_static(b"hi"), peer)).await.unwrap();
921 let _ = st.recv_bytes().await.unwrap();
922 assert!(!st.has_migration_candidate(), "the peer is not a candidate");
923 assert!(
924 !st.send_to_candidate(b"x").await.unwrap(),
925 "no candidate => Ok(false)"
926 );
927
928 // A frame from a NEW source registers a candidate + seeds the 3× budget
929 // (10 received bytes here).
930 let cand_sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
931 let cand_addr = cand_sock.local_addr().unwrap();
932 tx.send((Bytes::from_static(b"0123456789"), cand_addr))
933 .await
934 .unwrap();
935 let _ = st.recv_bytes().await.unwrap();
936 // M-1: the candidate is committed only on the post-decrypt (authenticated) path.
937 st.confirm_authenticated_source();
938 assert!(
939 st.has_migration_candidate(),
940 "a new source must set a candidate"
941 );
942
943 // A challenge within budget is delivered to the candidate address.
944 assert!(
945 st.send_to_candidate(b"chal").await.unwrap(),
946 "first challenge is within the 3× budget"
947 );
948 let mut buf = vec![0u8; 2048];
949 let (n, _from) = cand_sock.recv_from(&mut buf).await.unwrap();
950 assert!(n > 0, "the challenge must reach the candidate socket");
951
952 // Keep challenging until the 3× anti-amplification cap blocks.
953 let mut blocked = false;
954 for _ in 0..50 {
955 if !st.send_to_candidate(b"chal").await.unwrap() {
956 blocked = true;
957 break;
958 }
959 }
960 assert!(
961 blocked,
962 "the 3× anti-amplification cap must eventually block"
963 );
964 }
965
966 /// P4.2: promote_candidate atomically switches the established peer to the
967 /// validated candidate; subsequent send_bytes targets the new address.
968 #[tokio::test]
969 async fn promote_candidate_switches_the_peer() {
970 use tokio::sync::mpsc;
971 let server_sock = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
972 let old_peer_sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
973 let old_peer = old_peer_sock.local_addr().unwrap();
974 let new_sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
975 let new_addr = new_sock.local_addr().unwrap();
976
977 let (tx, rx) = mpsc::channel(8);
978 let ust = UdpServerTransport::new(server_sock.clone(), old_peer, [7u8; 8], tx.clone(), rx);
979 ust.set_frame_phase(FramePhase::Established);
980
981 assert!(
982 !ust.promote_candidate(),
983 "no candidate => nothing to promote"
984 );
985
986 // A frame from a new source sets the candidate (once it authenticates — M-1).
987 tx.send((Bytes::from_static(b"hi"), new_addr))
988 .await
989 .unwrap();
990 let _ = ust.recv_bytes().await.unwrap();
991 ust.confirm_authenticated_source();
992 assert!(ust.has_migration_candidate());
993
994 // Pre-switch: send_bytes goes to the OLD peer.
995 ust.send_bytes(b"before").await.unwrap();
996 let mut buf = vec![0u8; 512];
997 let (n, _) =
998 tokio::time::timeout(Duration::from_secs(1), old_peer_sock.recv_from(&mut buf))
999 .await
1000 .expect("pre-switch data reaches the old peer")
1001 .unwrap();
1002 assert!(n > 0);
1003
1004 // Switch.
1005 assert!(ust.promote_candidate(), "candidate must be promoted");
1006 assert!(
1007 !ust.has_migration_candidate(),
1008 "candidate cleared after promotion"
1009 );
1010
1011 // Post-switch: send_bytes now goes to the NEW peer.
1012 ust.send_bytes(b"after").await.unwrap();
1013 let (n2, _) = tokio::time::timeout(Duration::from_secs(1), new_sock.recv_from(&mut buf))
1014 .await
1015 .expect("post-switch data reaches the new peer")
1016 .unwrap();
1017 assert!(n2 > 0);
1018 }
1019
1020 /// D2 (server migration): `migrate_to` binds a fresh local socket, switches the server's
1021 /// SEND socket to it (so server→client datagrams egress a new source the client follows),
1022 /// and spawns a recv loop on it feeding the SAME inbound channel `recv_bytes` reads — so
1023 /// once the peer sends c2s to the new server address, its frames are delivered
1024 /// transparently (the listener demux keeps the old path alive during the overlap).
1025 #[tokio::test]
1026 async fn server_migrate_to_switches_send_socket_and_receives_on_it() {
1027 use tokio::sync::mpsc;
1028 let listener_sock = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
1029 let listen_addr = listener_sock.local_addr().unwrap();
1030 let peer_sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
1031 let peer = peer_sock.local_addr().unwrap();
1032 let (tx, rx) = mpsc::channel(16);
1033 let st = UdpServerTransport::new(listener_sock.clone(), peer, [4u8; 8], tx.clone(), rx);
1034 st.set_frame_phase(FramePhase::Established);
1035
1036 // Pre-migration: the server's s2c egresses the (shared) listener socket.
1037 st.send_bytes(b"pre").await.unwrap();
1038 let mut buf = vec![0u8; 2048];
1039 let (_n, src_pre) = peer_sock.recv_from(&mut buf).await.unwrap();
1040 assert_eq!(
1041 src_pre, listen_addr,
1042 "pre-migration s2c egresses the listener socket"
1043 );
1044
1045 // Migrate the server's send path to a fresh local socket.
1046 st.migrate_to("127.0.0.1:0".parse().unwrap())
1047 .await
1048 .expect("server migrate binds a new socket");
1049
1050 // Post-migration: the server's s2c egresses the NEW socket — a different source the
1051 // client's unconnected socket (D1) can hear and follow.
1052 st.send_bytes(b"post").await.unwrap();
1053 let (_n2, src_post) = peer_sock.recv_from(&mut buf).await.unwrap();
1054 assert_ne!(
1055 src_post, src_pre,
1056 "server migration changes the s2c source address"
1057 );
1058
1059 // The peer now sends c2s to the new server address; the migration recv loop must
1060 // forward it into the same channel `recv_bytes` reads.
1061 for d in
1062 encode_datagrams(PacketType::OneRtt, &[4u8; 8], 1, b"c2s-after-server-move").unwrap()
1063 {
1064 peer_sock.send_to(&d, src_post).await.unwrap();
1065 }
1066 let got = tokio::time::timeout(Duration::from_secs(2), st.recv_bytes())
1067 .await
1068 .expect("c2s on the migrated server socket reaches recv_bytes")
1069 .expect("recv");
1070 assert_eq!(&got[..], b"c2s-after-server-move");
1071 }
1072
1073 /// D3 (server-migration follow): the CLIENT mirrors the server's candidate machinery — a
1074 /// NEW server source becomes a candidate ONLY post-authentication (M-1), a `PATH_CHALLENGE`
1075 /// reaches it under the 3× anti-amplification cap, and `promote_candidate` re-points
1076 /// `server_addr` so subsequent c2s flows to the migrated server.
1077 #[tokio::test]
1078 async fn client_detects_server_candidate_and_promotes_to_new_server_addr() {
1079 let orig_server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
1080 let orig_addr = orig_server.local_addr().unwrap();
1081 let client = UdpClientTransport::connect(orig_addr).await.unwrap();
1082 client.set_frame_phase(FramePhase::Established);
1083
1084 assert!(!client.has_migration_candidate());
1085 assert!(
1086 !client.send_to_candidate(b"x").await.unwrap(),
1087 "no candidate => Ok(false)"
1088 );
1089
1090 // Learn the client's local address so the migrated server can target it.
1091 client.send_bytes(b"hi").await.unwrap();
1092 let mut buf = vec![0u8; 2048];
1093 let (_n, client_addr) = orig_server.recv_from(&mut buf).await.unwrap();
1094
1095 // A migrated server (a new source) sends a framed datagram; recv_bytes records it.
1096 let new_server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
1097 for d in encode_datagrams(PacketType::OneRtt, &client.cid(), 1, b"0123456789").unwrap() {
1098 new_server.send_to(&d, client_addr).await.unwrap();
1099 }
1100 let _ = tokio::time::timeout(Duration::from_secs(2), client.recv_bytes())
1101 .await
1102 .expect("no timeout")
1103 .expect("recv");
1104 // M-1: a recv alone must NOT commit a candidate (it is not yet AEAD-verified).
1105 assert!(
1106 !client.has_migration_candidate(),
1107 "recv alone must NOT commit a candidate (M-1)"
1108 );
1109 client.confirm_authenticated_source();
1110 assert!(
1111 client.has_migration_candidate(),
1112 "an authenticated new server source sets the candidate"
1113 );
1114
1115 // A challenge reaches the candidate (the migrated server) within the 3× budget.
1116 assert!(
1117 client.send_to_candidate(b"chal").await.unwrap(),
1118 "first challenge is within the 3× budget"
1119 );
1120 let (cn, _) = new_server.recv_from(&mut buf).await.unwrap();
1121 assert!(cn > 0, "the challenge must reach the new server socket");
1122
1123 // Promote → `server_addr` switches to the new server; subsequent send_bytes go there.
1124 assert!(client.promote_candidate(), "candidate must be promoted");
1125 assert!(
1126 !client.has_migration_candidate(),
1127 "candidate cleared after promotion"
1128 );
1129 client.send_bytes(b"after").await.unwrap();
1130 let (an, _) = tokio::time::timeout(Duration::from_secs(1), new_server.recv_from(&mut buf))
1131 .await
1132 .expect("post-promote c2s reaches the migrated server")
1133 .unwrap();
1134 assert!(an > 0);
1135 }
1136
1137 /// Review finding (overlap-drop robustness): a client mid-(local)-migration overlap must
1138 /// retire its old socket on the first well-formed datagram on the NEW socket REGARDLESS
1139 /// of source — including from a server that has itself migrated to a new address. A
1140 /// `src == server_addr` check would never fire for a migrated server and strand the
1141 /// overlap (an idle extra socket for the session's life).
1142 #[tokio::test]
1143 async fn overlap_ends_on_data_from_a_migrated_server_source() {
1144 let server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
1145 let server_addr = server.local_addr().unwrap();
1146 let client = UdpClientTransport::connect(server_addr).await.unwrap();
1147 client.set_frame_phase(FramePhase::Established);
1148
1149 client.send_bytes(b"hi").await.unwrap();
1150 let mut buf = vec![0u8; 2048];
1151 let (_n, _src_old) = server.recv_from(&mut buf).await.unwrap();
1152
1153 // Enter a (client-local) migration overlap.
1154 client
1155 .migrate_to("127.0.0.1:0".parse().unwrap())
1156 .await
1157 .unwrap();
1158 assert!(
1159 client.in_migration_overlap(),
1160 "migrate_to enters the dual-socket overlap"
1161 );
1162
1163 // Learn the new client socket's address (so a third party can target it).
1164 client.send_bytes(b"probe").await.unwrap();
1165 let (_n2, client_new_addr) = server.recv_from(&mut buf).await.unwrap();
1166
1167 // A datagram from a DIFFERENT source than the original server (a migrated server's
1168 // fresh socket) arrives on the new socket — it must be delivered AND end the overlap.
1169 let migrated_server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
1170 for d in encode_datagrams(
1171 PacketType::OneRtt,
1172 &client.cid(),
1173 1,
1174 b"from-migrated-server",
1175 )
1176 .unwrap()
1177 {
1178 migrated_server.send_to(&d, client_new_addr).await.unwrap();
1179 }
1180 let got = tokio::time::timeout(Duration::from_secs(2), client.recv_bytes())
1181 .await
1182 .expect("no timeout")
1183 .expect("recv");
1184 assert_eq!(&got[..], b"from-migrated-server");
1185 assert!(
1186 !client.in_migration_overlap(),
1187 "a well-formed datagram on the new socket (even from a migrated server source) \
1188 must end the overlap"
1189 );
1190 }
1191
1192 /// Review finding (H-1 reaping, refutation + guard): adding a `tx` clone to
1193 /// `UdpServerTransport` (so a server migration's recv loop can feed the same channel)
1194 /// does NOT break the demux's route reaping. `mpsc::Sender::is_closed()` tracks the
1195 /// RECEIVER being dropped, not the sender-clone count — so when the transport (which owns
1196 /// the `rx`) is dropped at session end, a sibling `tx` clone the demux retains for routing
1197 /// immediately observes `is_closed() == true` and the route is reclaimed.
1198 #[tokio::test]
1199 async fn dropping_the_transport_closes_the_demux_tx_clone() {
1200 use tokio::sync::mpsc;
1201 let sock = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
1202 let peer = UdpSocket::bind("127.0.0.1:0")
1203 .await
1204 .unwrap()
1205 .local_addr()
1206 .unwrap();
1207 let (tx, rx) = mpsc::channel(8);
1208 // The RouteTable retains a sibling clone for routing; the transport gets another.
1209 let demux_clone = tx.clone();
1210 let st = UdpServerTransport::new(sock, peer, [1u8; 8], tx.clone(), rx);
1211 assert!(
1212 !demux_clone.is_closed(),
1213 "a live session's route stays open"
1214 );
1215 // Session ends: the transport (holding `rx` + its own `tx` clone) is dropped.
1216 drop(st);
1217 assert!(
1218 demux_clone.is_closed(),
1219 "the demux's tx clone observes the dropped receiver (is_closed tracks the \
1220 receiver, not the sender-clone count), so the route is reaped — the transport's \
1221 extra tx clone does not strand it"
1222 );
1223 }
1224
1225 /// P4.2b: `migrate()` rebinds the client to a fresh (still unconnected) local socket
1226 /// that keeps `send_to`-ing the same `server_addr`, so the client's source address
1227 /// changes — which is what makes the server detect the new path (P4.1). The new socket
1228 /// becomes the active send/recv socket; a reply to the new source is received.
1229 #[tokio::test]
1230 async fn migrate_rebinds_to_a_new_local_socket() {
1231 let server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
1232 let server_addr = server.local_addr().unwrap();
1233 let client = UdpClientTransport::connect(server_addr).await.unwrap();
1234 client.set_frame_phase(FramePhase::Established);
1235
1236 // Pre-migration: the server sees the original source.
1237 client.send_bytes(b"pre").await.unwrap();
1238 let mut buf = vec![0u8; 2048];
1239 let (_n, src_old) = server.recv_from(&mut buf).await.unwrap();
1240
1241 // Migrate to a fresh ephemeral local socket.
1242 client
1243 .migrate_to("127.0.0.1:0".parse().unwrap())
1244 .await
1245 .expect("migrate binds a new socket");
1246
1247 // Post-migration: the server sees a DIFFERENT source (the new socket).
1248 client.send_bytes(b"post").await.unwrap();
1249 let (_n2, src_new) = server.recv_from(&mut buf).await.unwrap();
1250 assert_ne!(
1251 src_old, src_new,
1252 "migrate() must change the client's source address"
1253 );
1254
1255 // A reply to the new source is received on the new (active) socket.
1256 for d in encode_datagrams(PacketType::OneRtt, &client.cid(), 7, b"reply-new").unwrap() {
1257 server.send_to(&d, src_new).await.unwrap();
1258 }
1259 let got = tokio::time::timeout(Duration::from_secs(2), client.recv_bytes())
1260 .await
1261 .expect("no timeout")
1262 .expect("recv");
1263 assert_eq!(&got[..], b"reply-new");
1264 }
1265
1266 /// P4.2b: during the migration overlap the client keeps the OLD socket and still
1267 /// receives on it (broken-rebind safety / D7) — the server, until it validates +
1268 /// swaps, keeps sending downstream app data to the old address. The session must
1269 /// not lose that data.
1270 #[tokio::test]
1271 async fn migrate_keeps_receiving_on_the_old_socket_during_overlap() {
1272 let server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
1273 let server_addr = server.local_addr().unwrap();
1274 let client = UdpClientTransport::connect(server_addr).await.unwrap();
1275 client.set_frame_phase(FramePhase::Established);
1276
1277 client.send_bytes(b"hi").await.unwrap();
1278 let mut buf = vec![0u8; 2048];
1279 let (_n, src_old) = server.recv_from(&mut buf).await.unwrap();
1280
1281 client
1282 .migrate_to("127.0.0.1:0".parse().unwrap())
1283 .await
1284 .expect("migrate binds a new socket");
1285
1286 // The server has NOT yet validated/swapped, so it still sends downstream to
1287 // the OLD source. The client must still receive it on the retained old socket.
1288 for d in encode_datagrams(PacketType::OneRtt, &client.cid(), 1, b"downstream-old").unwrap()
1289 {
1290 server.send_to(&d, src_old).await.unwrap();
1291 }
1292 let got = tokio::time::timeout(Duration::from_secs(2), client.recv_bytes())
1293 .await
1294 .expect("no timeout")
1295 .expect("recv on retained old socket");
1296 assert_eq!(&got[..], b"downstream-old");
1297 }
1298
1299 /// P4.2c: the SocketAddr-free `migrate(String)` trait entry parses the address; a
1300 /// malformed address is a clean `Err` and leaves the session untouched on the old
1301 /// socket (best-effort, never fatal). The session keeps working afterwards.
1302 #[tokio::test]
1303 async fn migrate_with_a_bad_local_addr_is_a_clean_error() {
1304 let server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
1305 let server_addr = server.local_addr().unwrap();
1306 let client = UdpClientTransport::connect(server_addr).await.unwrap();
1307 client.set_frame_phase(FramePhase::Established);
1308
1309 // Unparseable address → Err, no rebind (the SocketAddr-free trait entry).
1310 let err = client.migrate("not-an-address".to_string()).await;
1311 assert!(err.is_err(), "a malformed local addr must be a clean Err");
1312
1313 // The session still works on the original socket.
1314 client.send_bytes(b"still-alive").await.unwrap();
1315 let mut buf = vec![0u8; 2048];
1316 let (n, _from) = tokio::time::timeout(Duration::from_secs(2), server.recv_from(&mut buf))
1317 .await
1318 .expect("no timeout")
1319 .unwrap();
1320 assert!(
1321 n > 0,
1322 "data still flows on the original socket after a failed migrate"
1323 );
1324 }
1325
1326 /// M-1 (audit 2026-06-11): the migration candidate (the server's PATH_CHALLENGE target)
1327 /// must be registered only from an AEAD-AUTHENTICATED frame, not from any CID-matched
1328 /// datagram's raw source — else a spoofed source can clobber the single candidate slot and
1329 /// misdirect / stall a legitimate migration. `recv_bytes` records but does not commit; the
1330 /// post-decrypt `confirm_authenticated_source` commits.
1331 #[tokio::test]
1332 async fn candidate_is_set_only_from_an_authenticated_source() {
1333 use tokio::sync::mpsc;
1334 let sock = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
1335 let peer = UdpSocket::bind("127.0.0.1:0")
1336 .await
1337 .unwrap()
1338 .local_addr()
1339 .unwrap();
1340 let (tx, rx) = mpsc::channel(8);
1341 let st = UdpServerTransport::new(sock.clone(), peer, [5u8; 8], tx.clone(), rx);
1342
1343 // A frame from a NEW source arrives but has not yet been AEAD-verified (pre-decrypt) —
1344 // exactly what a spoofed CID-matched datagram looks like at recv time.
1345 let other = UdpSocket::bind("127.0.0.1:0")
1346 .await
1347 .unwrap()
1348 .local_addr()
1349 .unwrap();
1350 tx.send((Bytes::from_static(b"pre-decrypt-source"), other))
1351 .await
1352 .unwrap();
1353 let _ = st.recv_bytes().await.unwrap();
1354 assert!(
1355 !st.has_migration_candidate(),
1356 "a pre-decrypt (possibly spoofed) source must NOT set the migration candidate (M-1)"
1357 );
1358
1359 // Once that frame authenticates (AEAD-opens), the post-decrypt path commits it.
1360 st.confirm_authenticated_source();
1361 assert!(
1362 st.has_migration_candidate(),
1363 "an AEAD-authenticated new source sets the migration candidate"
1364 );
1365 }
1366
1367 /// M-6 (audit 2026-06-11): an ICMP-induced recv error (the forged-RST analogue — a spoofed
1368 /// "port unreachable") must be treated as ADVISORY and retried, never mapped to a fatal
1369 /// `NetworkError` that tears the session down bypassing the liveness machinery (RFC 8085
1370 /// §5.5 / RFC 9000 §14.2). A genuine error stays fatal; a datagram carries its source.
1371 #[test]
1372 fn advisory_icmp_recv_errors_are_retried_not_fatal() {
1373 use std::io::{Error, ErrorKind};
1374 assert!(matches!(
1375 classify_recv(Err(Error::from(ErrorKind::ConnectionRefused))),
1376 RecvAction::Retry
1377 ));
1378 assert!(matches!(
1379 classify_recv(Err(Error::from(ErrorKind::ConnectionReset))),
1380 RecvAction::Retry
1381 ));
1382 assert!(matches!(
1383 classify_recv(Err(Error::from(ErrorKind::PermissionDenied))),
1384 RecvAction::Fatal(_)
1385 ));
1386 let addr: SocketAddr = "127.0.0.1:9".parse().unwrap();
1387 assert!(matches!(
1388 classify_recv(Ok((42, addr))),
1389 RecvAction::Got(42, s) if s == addr
1390 ));
1391 }
1392}