Skip to content
File

Blob: firmware/vendor/str0m/tests/dtls-retransmit.rs

rust334 lines
1//! Regression test for the DTLS retransmit bug fixed in PR #943 (refs #932).
2//!
3//! When a DTLS flight timeout elapses, dimpl's `handle_timeout` re-arms the
4//! flight timer and queues the retransmit packet. Previously str0m ran
5//! `handle_timeout` at the end of `do_poll_output`, after both the DTLS
6//! `poll_output` loop and `dtls.poll_packet()` had already run — so the
7//! freshly queued retransmit was left sitting in dimpl's tx queue until
8//! some unrelated subsystem (typically an ICE consent check) woke the
9//! caller for another poll pass.
10//!
11//! This test drives two peers to the mid-handshake state (L has sent
12//! ClientHello, R ignores DTLS so never replies), advances L's clock past
13//! the flight deadline with a single `handle_input(Timeout)`, then asserts
14//! that a single `poll_output` pass emits the retransmit as a `Transmit`.
15//! Without the fix, the pass instead returns `Timeout` and the retransmit
16//! is stranded.
17//!
18//! The scenario asserted here is specific to dimpl's tx-queue semantics,
19//! so the test is compiled only under dimpl-backed crypto providers.
20#![cfg(any(
21 feature = "aws-lc-rs",
22 feature = "rust-crypto",
23 feature = "openssl-dimpl",
24 feature = "wincrypto-dimpl",
25 feature = "apple-crypto",
26))]
27use std::net::Ipv4Addr;
28use std::time::{Duration, Instant};
29 
30use netem::{NetemConfig, Probability, RandomLoss};
31use str0m::RtcError;
32use str0m::config::DtlsVersion;
33use str0m::{Candidate, Input, Output, Reason, Rtc};
34use tracing::info_span;
35 
36mod common;
37use common::{TestRtc, init_crypto_default, progress};
38 
39#[test]
40fn dtls_retransmit_emitted_in_same_poll_pass() {
41 init_crypto_default();
42 
43 let now = Instant::now();
44 let mut l = TestRtc::new_with_rtc(info_span!("L"), Rtc::new(now));
45 let mut r = TestRtc::new_with_rtc(info_span!("R"), Rtc::new(now));
46 
47 let host_l = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp").unwrap();
48 let host_r = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp").unwrap();
49 l.add_local_candidate(host_l.clone());
50 l.add_remote_candidate(host_r.clone());
51 r.add_local_candidate(host_r);
52 r.add_remote_candidate(host_l);
53 
54 let finger_l = l.direct_api().local_dtls_fingerprint().clone();
55 let finger_r = r.direct_api().local_dtls_fingerprint().clone();
56 l.direct_api().set_remote_fingerprint(finger_r);
57 r.direct_api().set_remote_fingerprint(finger_l);
58 
59 let creds_l = l.direct_api().local_ice_credentials();
60 let creds_r = r.direct_api().local_ice_credentials();
61 l.direct_api().set_remote_ice_credentials(creds_r);
62 r.direct_api().set_remote_ice_credentials(creds_l);
63 
64 l.direct_api().set_ice_controlling(true);
65 r.direct_api().set_ice_controlling(false);
66 
67 // Only L starts DTLS. R receives L's ClientHello but ignores it
68 // (active_state is None in dtls::handle_receive), so L never gets a
69 // ServerHello and must retransmit.
70 l.direct_api().start_dtls(true).unwrap();
71 
72 // Drive both sides until L is awaiting the DTLS flight deadline.
73 let mut steps = 0;
74 while l.rtc.last_timeout_reason() != Reason::DTLS {
75 progress(&mut l, &mut r).unwrap();
76 steps += 1;
77 assert!(
78 steps < 200,
79 "failed to reach DTLS-awaiting state (last reason: {:?})",
80 l.rtc.last_timeout_reason()
81 );
82 }
83 
84 // Advance past the flight deadline in one step with no network input.
85 // The fix causes the following poll_output pass to emit the queued
86 // retransmit. Without the fix, it returns Output::Timeout and the
87 // retransmit stays stranded in dimpl.
88 let deadline = l.last + Duration::from_millis(1500);
89 l.rtc.handle_input(Input::Timeout(deadline)).unwrap();
90 
91 let mut got_transmit = false;
92 loop {
93 match l.rtc.poll_output().unwrap() {
94 Output::Timeout(_) => break,
95 Output::Transmit(_) => {
96 got_transmit = true;
97 break;
98 }
99 Output::Event(_) => continue,
100 }
101 }
102 
103 assert!(
104 got_transmit,
105 "poll_output should emit the DTLS retransmit in the same pass that runs handle_timeout"
106 );
107}
108 
109/// A terminal dimpl handshake timeout must be returned and must leave the
110/// `Rtc` inert rather than continually returning the expired DTLS deadline.
111///
112/// This runs for every dimpl-backed crypto provider. It drives the scheduler
113/// with the exact timeout returned by `Rtc`, which is the behavior that
114/// previously caused an immediate-wake loop after dimpl had exhausted its
115/// retry budget.
116#[test]
117fn terminal_dtls_12_to_auto_timeout_does_not_rearm_expired_deadline() {
118 terminal_dtls_timeout_does_not_rearm_expired_deadline_for(
119 DtlsVersion::Dtls12,
120 DtlsVersion::Auto,
121 );
122}
123 
124#[test]
125fn terminal_dtls_13_to_auto_timeout_does_not_rearm_expired_deadline() {
126 terminal_dtls_timeout_does_not_rearm_expired_deadline_for(
127 DtlsVersion::Dtls13,
128 DtlsVersion::Auto,
129 );
130}
131 
132#[test]
133fn terminal_dtls_auto_to_auto_timeout_does_not_rearm_expired_deadline() {
134 terminal_dtls_timeout_does_not_rearm_expired_deadline_for(DtlsVersion::Auto, DtlsVersion::Auto);
135}
136 
137#[test]
138fn terminal_dtls_12_to_12_timeout_does_not_rearm_expired_deadline() {
139 terminal_dtls_timeout_does_not_rearm_expired_deadline_for(
140 DtlsVersion::Dtls12,
141 DtlsVersion::Dtls12,
142 );
143}
144 
145#[test]
146fn terminal_dtls_13_to_13_timeout_does_not_rearm_expired_deadline() {
147 terminal_dtls_timeout_does_not_rearm_expired_deadline_for(
148 DtlsVersion::Dtls13,
149 DtlsVersion::Dtls13,
150 );
151}
152 
153fn terminal_dtls_timeout_does_not_rearm_expired_deadline_for(
154 client_dtls: DtlsVersion,
155 server_dtls: DtlsVersion,
156) {
157 init_crypto_default();
158 
159 let now = Instant::now();
160 let left_rtc = Rtc::builder().set_dtls_version(client_dtls).build(now);
161 let right_rtc = Rtc::builder().set_dtls_version(server_dtls).build(now);
162 let mut left = TestRtc::new_with_rtc(info_span!("L"), left_rtc);
163 let mut right = TestRtc::new_with_rtc(info_span!("R"), right_rtc);
164 
165 // Responses from the passive peer are discarded after it receives the
166 // client's first flight, so the active peer retries until dimpl times out.
167 left.set_netem(
168 NetemConfig::new()
169 .loss(RandomLoss::new(Probability::new(1.0)))
170 .seed(1),
171 );
172 
173 let host_left = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp").unwrap();
174 let host_right = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp").unwrap();
175 left.add_local_candidate(host_left.clone());
176 left.add_remote_candidate(host_right.clone());
177 right.add_local_candidate(host_right);
178 right.add_remote_candidate(host_left);
179 
180 let fingerprint_left = left.direct_api().local_dtls_fingerprint().clone();
181 let fingerprint_right = right.direct_api().local_dtls_fingerprint().clone();
182 left.direct_api().set_remote_fingerprint(fingerprint_right);
183 right.direct_api().set_remote_fingerprint(fingerprint_left);
184 
185 let credentials_left = left.direct_api().local_ice_credentials();
186 let credentials_right = right.direct_api().local_ice_credentials();
187 left.direct_api()
188 .set_remote_ice_credentials(credentials_right);
189 right
190 .direct_api()
191 .set_remote_ice_credentials(credentials_left);
192 
193 left.direct_api().set_ice_controlling(true);
194 right.direct_api().set_ice_controlling(false);
195 left.direct_api().start_dtls(true).unwrap();
196 right.direct_api().start_dtls(false).unwrap();
197 
198 // Let the first flight reach the passive peer and its response be dropped.
199 let mut setup_steps = 0;
200 while left.rtc.last_timeout_reason() != Reason::DTLS {
201 progress(&mut left, &mut right).unwrap();
202 setup_steps += 1;
203 assert!(
204 setup_steps < 200,
205 "failed to reach a DTLS flight deadline (last reason: {:?})",
206 left.rtc.last_timeout_reason()
207 );
208 }
209 
210 // Unlike TestRtc::progress, preserve an immediate timeout exactly. This
211 // models a scheduler that runs work scheduled for now without adding a
212 // retry delay of its own.
213 let mut last_now = left.last;
214 let mut terminal_error = None;
215 for _ in 0..1_000 {
216 left.rtc.handle_input(Input::Timeout(last_now)).unwrap();
217 
218 loop {
219 match left.rtc.poll_output() {
220 Err(RtcError::Dtls(error)) => {
221 terminal_error = Some(error);
222 break;
223 }
224 Err(error) => panic!("unexpected RTC error: {error}"),
225 Ok(Output::Timeout(timeout)) => {
226 last_now = timeout;
227 break;
228 }
229 Ok(Output::Transmit(_)) => {
230 // Drop each retransmitted DTLS flight.
231 }
232 Ok(Output::Event(_)) => {
233 // ICE may report its disconnected state before DTLS gives up.
234 }
235 }
236 }
237 
238 if terminal_error.is_some() {
239 break;
240 }
241 }
242 
243 assert!(
244 terminal_error.is_some(),
245 "terminal dimpl timeout was not returned within the bounded scheduler run"
246 );
247 assert!(
248 !left.rtc.is_alive(),
249 "terminal DTLS error must close the RTC"
250 );
251 
252 // After the error, callers that continue polling and feeding timeout input
253 // must not receive another due timeout, packet, or event.
254 let terminal_last_now = last_now;
255 for _ in 0..16 {
256 left.rtc
257 .handle_input(Input::Timeout(terminal_last_now))
258 .unwrap();
259 
260 match left.rtc.poll_output().unwrap() {
261 Output::Timeout(timeout) => {
262 assert!(
263 timeout > terminal_last_now,
264 "closed RTC returned a timeout at or before its last input"
265 );
266 }
267 Output::Transmit(_) => panic!("closed RTC produced a DTLS transmission"),
268 Output::Event(event) => panic!("closed RTC produced an event: {event:?}"),
269 }
270 }
271}
272 
273/// End-to-end: a DTLS handshake between two peers must complete even
274/// when the link drops packets. Before the fix, dropped handshake
275/// packets stalled for seconds at a time because each lost flight's
276/// retransmit was deferred to the next unrelated wake-up — on a lossy
277/// link the handshake could miss its connect deadline entirely.
278#[test]
279fn dtls_handshake_completes_under_packet_loss() {
280 init_crypto_default();
281 
282 let now = Instant::now();
283 let mut l = TestRtc::new_with_rtc(info_span!("L"), Rtc::new(now));
284 let mut r = TestRtc::new_with_rtc(info_span!("R"), Rtc::new(now));
285 
286 // 30% random loss in both directions from the very first packet.
287 let lossy = NetemConfig::new()
288 .loss(RandomLoss::new(Probability::new(0.3)))
289 .seed(1);
290 l.set_netem(lossy.clone());
291 r.set_netem(lossy);
292 
293 let host_l = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp").unwrap();
294 let host_r = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp").unwrap();
295 l.add_local_candidate(host_l.clone());
296 l.add_remote_candidate(host_r.clone());
297 r.add_local_candidate(host_r);
298 r.add_remote_candidate(host_l);
299 
300 let finger_l = l.direct_api().local_dtls_fingerprint().clone();
301 let finger_r = r.direct_api().local_dtls_fingerprint().clone();
302 l.direct_api().set_remote_fingerprint(finger_r);
303 r.direct_api().set_remote_fingerprint(finger_l);
304 
305 let creds_l = l.direct_api().local_ice_credentials();
306 let creds_r = r.direct_api().local_ice_credentials();
307 l.direct_api().set_remote_ice_credentials(creds_r);
308 r.direct_api().set_remote_ice_credentials(creds_l);
309 
310 l.direct_api().set_ice_controlling(true);
311 r.direct_api().set_ice_controlling(false);
312 
313 l.direct_api().start_dtls(true).unwrap();
314 r.direct_api().start_dtls(false).unwrap();
315 l.direct_api().start_sctp(true);
316 r.direct_api().start_sctp(false);
317 
318 // Drive both sides forward. The DTLS default connect timeout is 10s,
319 // so a bounded iteration cap that allows well past that is enough to
320 // tell whether the handshake is making progress at all.
321 let mut iterations = 0;
322 while !(l.is_connected() && r.is_connected()) {
323 progress(&mut l, &mut r).unwrap();
324 iterations += 1;
325 assert!(
326 iterations < 5000,
327 "DTLS handshake did not complete under 30% packet loss \
328 (l_connected={}, r_connected={})",
329 l.is_connected(),
330 r.is_connected()
331 );
332 }
333}