1use std::io;
7use std::net::{SocketAddr, UdpSocket};
8use std::time::{Duration, Instant};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use hmac::{Hmac, Mac};
12use rand::rngs::OsRng;
13use rand::RngCore;
14use sha2::Sha256;
15
16use crc32fast::Hasher;
17
18use crate::protocol::{DiscoveryFrame, FrameKind, NodeResources};
19
20#[derive(Clone, Debug, Default, PartialEq, Eq)]
21pub struct DiscoveryDropCounters {
22 pub malformed: usize,
23 pub auth_mismatch: usize,
24 pub unsupported_kind: usize,
25}
26
27#[derive(Clone, Debug, Default, PartialEq, Eq)]
28pub struct DiscoveryServeStats {
29 pub replies_sent: usize,
30 pub drops: DiscoveryDropCounters,
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
34enum DatagramDecodeError {
35 DatagramTooShortForAuth,
36 InvalidAuthTrailerLength,
37 UnsupportedAuthProtocolVersion(u8),
38 AuthTimestampOutsideWindow,
39 ReplayNonceDetected,
40 AuthTagMismatch,
41 FrameDecode(String),
42}
43
44impl std::fmt::Display for DatagramDecodeError {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 match self {
47 Self::DatagramTooShortForAuth => {
48 write!(f, "discovery datagram too short for auth trailer")
49 }
50 Self::InvalidAuthTrailerLength => write!(f, "invalid auth trailer length"),
51 Self::UnsupportedAuthProtocolVersion(version) => {
52 write!(f, "unsupported discovery auth protocol version {version}")
53 }
54 Self::AuthTimestampOutsideWindow => {
55 write!(
56 f,
57 "discovery datagram auth timestamp outside allowed window"
58 )
59 }
60 Self::ReplayNonceDetected => {
61 write!(f, "discovery datagram replay nonce detected")
62 }
63 Self::AuthTagMismatch => write!(f, "discovery datagram auth tag mismatch"),
64 Self::FrameDecode(message) => write!(f, "{message}"),
65 }
66 }
67}
68
69const DISCOVERY_AUTH_PROTOCOL_VERSION: u8 = 2;
70const DISCOVERY_AUTH_MARKER: [u8; 2] = *b"GL";
71const DISCOVERY_AUTH_TAG_LEN: usize = 32;
72const DISCOVERY_NONCE_LEN: usize = 16;
73const DISCOVERY_AUTH_TRAILER_LEN: usize = 2 + 1 + 8 + DISCOVERY_NONCE_LEN + DISCOVERY_AUTH_TAG_LEN;
74const DISCOVERY_AUTH_MAX_SKEW_SECS: u64 = 30;
75const DISCOVERY_NONCE_CACHE_CAPACITY: usize = 4096;
76
77type HmacSha256 = Hmac<Sha256>;
78
79#[derive(Default)]
80struct ReplayCache {
81 nonces: std::collections::HashSet<u128>,
82 order: std::collections::VecDeque<u128>,
83}
84
85impl ReplayCache {
86 fn check_and_insert(&mut self, nonce: u128) -> bool {
87 if self.nonces.contains(&nonce) {
88 return false;
89 }
90
91 self.nonces.insert(nonce);
92 self.order.push_back(nonce);
93 while self.order.len() > DISCOVERY_NONCE_CACHE_CAPACITY {
94 if let Some(evicted) = self.order.pop_front() {
95 self.nonces.remove(&evicted);
96 }
97 }
98
99 true
100 }
101}
102
103pub const DEFAULT_DISCOVERY_PORT: u16 = 45885;
105
106#[derive(Clone, Debug)]
108pub struct UdpDiscoveryConfig {
109 pub bind_addr: SocketAddr,
111 pub broadcast_addr: SocketAddr,
113 pub response_timeout: Duration,
115 pub auth_token: Option<String>,
117 pub enforce_auth: bool,
119 pub allow_legacy_crc32: bool,
121 pub max_datagram_size: usize,
123}
124
125impl Default for UdpDiscoveryConfig {
126 fn default() -> Self {
127 Self {
128 bind_addr: SocketAddr::from(([0, 0, 0, 0], 0)),
129 broadcast_addr: SocketAddr::from(([255, 255, 255, 255], DEFAULT_DISCOVERY_PORT)),
130 response_timeout: Duration::from_millis(750),
131 auth_token: None,
132 enforce_auth: false,
133 allow_legacy_crc32: false,
134 max_datagram_size: 2048,
135 }
136 }
137}
138
139pub fn broadcast_and_collect(
141 frame: &DiscoveryFrame,
142 config: &UdpDiscoveryConfig,
143) -> Result<Vec<(DiscoveryFrame, SocketAddr)>, String> {
144 if config.enforce_auth && config.auth_token.is_none() {
145 return Err(
146 "discovery config error: enforce_auth is true but no auth_token is configured; \
147 refusing to run with authentication silently disabled"
148 .to_string(),
149 );
150 }
151
152 let socket = UdpSocket::bind(config.bind_addr)
153 .map_err(|e| format!("failed to bind UDP discovery socket: {e}"))?;
154 socket
155 .set_broadcast(true)
156 .map_err(|e| format!("failed to enable UDP broadcast: {e}"))?;
157 socket
158 .set_read_timeout(Some(Duration::from_millis(100)))
159 .map_err(|e| format!("failed to configure UDP read timeout: {e}"))?;
160
161 let packet = encode_datagram(frame, config.auth_token.as_deref());
162 socket
163 .send_to(&packet, config.broadcast_addr)
164 .map_err(|e| format!("failed to send UDP discovery datagram: {e}"))?;
165
166 let mut peers = Vec::new();
167 let mut recv_buf = vec![0_u8; config.max_datagram_size.max(256)];
168 let deadline = Instant::now() + config.response_timeout;
169 let mut replay_cache = ReplayCache::default();
170
171 while Instant::now() < deadline {
172 match socket.recv_from(&mut recv_buf) {
173 Ok((read, addr)) => {
174 let datagram = &recv_buf[..read];
175
176 let decode_result = decode_datagram_with_options(
179 datagram,
180 config.auth_token.as_deref(),
181 config.allow_legacy_crc32,
182 Some(&mut replay_cache),
183 );
184
185 match decode_result {
186 Ok(decoded) => peers.push((decoded, addr)),
187 Err(err) => {
188 if config.enforce_auth {
189 tracing::warn!(
190 "Secure Discovery: Rejecting unauthenticated frame from {}: {}",
191 addr,
192 err
193 );
194 }
195 }
196 }
197 }
198 Err(err)
199 if matches!(
200 err.kind(),
201 io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
202 ) => {}
203 Err(err) => return Err(format!("failed while receiving UDP discovery reply: {err}")),
204 }
205 }
206
207 Ok(peers)
208}
209
210pub fn respond_once(
215 local_node: &NodeResources,
216 config: &UdpDiscoveryConfig,
217) -> Result<Option<SocketAddr>, String> {
218 if config.enforce_auth && config.auth_token.is_none() {
219 return Err(
220 "discovery config error: enforce_auth is true but no auth_token is configured; \
221 refusing to run with authentication silently disabled"
222 .to_string(),
223 );
224 }
225
226 let socket = UdpSocket::bind(config.bind_addr)
227 .map_err(|e| format!("failed to bind UDP discovery listener socket: {e}"))?;
228 socket
229 .set_read_timeout(Some(Duration::from_millis(100)))
230 .map_err(|e| format!("failed to configure UDP listener timeout: {e}"))?;
231
232 let mut recv_buf = vec![0_u8; config.max_datagram_size.max(256)];
233 let deadline = Instant::now() + config.response_timeout;
234 let mut replay_cache = ReplayCache::default();
235
236 while Instant::now() < deadline {
237 let (read, peer_addr) = match socket.recv_from(&mut recv_buf) {
238 Ok(parts) => parts,
239 Err(err)
240 if matches!(
241 err.kind(),
242 io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
243 ) =>
244 {
245 continue;
246 }
247 Err(err) => {
248 return Err(format!(
249 "failed while waiting for UDP discovery datagram: {err}"
250 ));
251 }
252 };
253
254 let decode_result = decode_datagram_with_options(
255 &recv_buf[..read],
256 config.auth_token.as_deref(),
257 config.allow_legacy_crc32,
258 Some(&mut replay_cache),
259 );
260
261 let incoming = match decode_result {
262 Ok(frame) => frame,
263 Err(err) => {
264 if config.enforce_auth {
265 tracing::warn!(
266 "Secure Responder: Dropping invalid frame from {}: {}",
267 peer_addr,
268 err
269 );
270 }
271 continue;
272 }
273 };
274
275 if !matches!(incoming.kind, FrameKind::Join | FrameKind::Discovery) {
276 continue;
277 }
278
279 let reply = DiscoveryFrame {
280 kind: FrameKind::Discovery,
281 node: local_node.clone(),
282 };
283 let encoded = encode_datagram(&reply, config.auth_token.as_deref());
284 socket
285 .send_to(&encoded, peer_addr)
286 .map_err(|e| format!("failed to send UDP discovery reply: {e}"))?;
287
288 return Ok(Some(peer_addr));
289 }
290
291 Ok(None)
292}
293
294pub fn serve_discovery(
299 local_node: &NodeResources,
300 config: &UdpDiscoveryConfig,
301 max_replies: Option<usize>,
302) -> Result<usize, String> {
303 serve_discovery_with_stats(local_node, config, max_replies).map(|stats| stats.replies_sent)
304}
305
306pub fn serve_discovery_with_stats(
307 local_node: &NodeResources,
308 config: &UdpDiscoveryConfig,
309 max_replies: Option<usize>,
310) -> Result<DiscoveryServeStats, String> {
311 if config.enforce_auth && config.auth_token.is_none() {
312 return Err(
313 "discovery config error: enforce_auth is true but no auth_token is configured; \
314 refusing to run with authentication silently disabled"
315 .to_string(),
316 );
317 }
318
319 let socket = UdpSocket::bind(config.bind_addr)
320 .map_err(|e| format!("failed to bind UDP discovery listener socket: {e}"))?;
321 socket
322 .set_read_timeout(Some(Duration::from_millis(250)))
323 .map_err(|e| format!("failed to configure UDP listener timeout: {e}"))?;
324
325 let reply = DiscoveryFrame {
326 kind: FrameKind::Discovery,
327 node: local_node.clone(),
328 };
329 let reply_bytes = encode_datagram(&reply, config.auth_token.as_deref());
330
331 let mut stats = DiscoveryServeStats::default();
332 let mut recv_buf = vec![0_u8; config.max_datagram_size.max(256)];
333 let mut replay_cache = ReplayCache::default();
334
335 loop {
336 if let Some(limit) = max_replies {
337 if stats.replies_sent >= limit {
338 return Ok(stats);
339 }
340 }
341
342 match socket.recv_from(&mut recv_buf) {
343 Ok((read, peer_addr)) => {
344 match decode_datagram_with_options(
345 &recv_buf[..read],
346 config.auth_token.as_deref(),
347 config.allow_legacy_crc32,
348 Some(&mut replay_cache),
349 ) {
350 Ok(incoming)
351 if matches!(incoming.kind, FrameKind::Join | FrameKind::Discovery) =>
352 {
353 if socket.send_to(&reply_bytes, peer_addr).is_ok() {
354 stats.replies_sent += 1;
355 }
356 }
357 Ok(_) => {
358 stats.drops.unsupported_kind += 1;
359 }
360 Err(DatagramDecodeError::AuthTagMismatch) => {
361 stats.drops.auth_mismatch += 1;
362 }
363 Err(_) => {
364 stats.drops.malformed += 1;
365 }
366 }
367 }
368 Err(err)
369 if matches!(
370 err.kind(),
371 io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
372 ) => {}
373 Err(err) => {
374 return Err(format!(
375 "failed while receiving UDP discovery request: {err}"
376 ));
377 }
378 }
379 }
380}
381
382fn encode_datagram(frame: &DiscoveryFrame, auth_token: Option<&str>) -> Vec<u8> {
383 let frame_bytes = frame.encode();
384 if let Some(token) = auth_token {
385 let mut datagram = frame_bytes;
386 let timestamp_secs = current_unix_timestamp_secs();
387 let nonce = random_nonce();
388 let tag = auth_tag_v2(&datagram, token, timestamp_secs, nonce);
389 datagram.extend_from_slice(&DISCOVERY_AUTH_MARKER);
390 datagram.push(DISCOVERY_AUTH_PROTOCOL_VERSION);
391 datagram.extend_from_slice(×tamp_secs.to_le_bytes());
392 datagram.extend_from_slice(&nonce.to_le_bytes());
393 datagram.extend_from_slice(&tag);
394 datagram
395 } else {
396 frame_bytes
397 }
398}
399
400#[cfg(test)]
401fn decode_datagram(
402 datagram: &[u8],
403 auth_token: Option<&str>,
404) -> Result<DiscoveryFrame, DatagramDecodeError> {
405 decode_datagram_with_options(datagram, auth_token, false, None)
406}
407
408fn decode_datagram_with_options(
409 datagram: &[u8],
410 auth_token: Option<&str>,
411 allow_legacy_crc32: bool,
412 replay_cache: Option<&mut ReplayCache>,
413) -> Result<DiscoveryFrame, DatagramDecodeError> {
414 if let Some(token) = auth_token {
415 if datagram.len() < DISCOVERY_AUTH_TRAILER_LEN {
416 return Err(DatagramDecodeError::DatagramTooShortForAuth);
417 }
418
419 let v2_result = decode_v2_auth_datagram(datagram, token, replay_cache);
420 if v2_result.is_ok() {
421 return v2_result;
422 }
423
424 if allow_legacy_crc32 {
425 tracing::warn!(
426 "Discovery auth fallback enabled: accepting legacy CRC32 checksum mode; this mode is compatibility-only and does not provide cryptographic authentication"
427 );
428 return decode_legacy_crc32_checksum_datagram(datagram, token);
429 }
430
431 v2_result
432 } else {
433 DiscoveryFrame::decode(datagram).map_err(DatagramDecodeError::FrameDecode)
434 }
435}
436
437fn decode_v2_auth_datagram(
438 datagram: &[u8],
439 token: &str,
440 replay_cache: Option<&mut ReplayCache>,
441) -> Result<DiscoveryFrame, DatagramDecodeError> {
442 let (frame_bytes, trailer) = datagram.split_at(datagram.len() - DISCOVERY_AUTH_TRAILER_LEN);
443
444 let marker = trailer
445 .get(0..2)
446 .ok_or(DatagramDecodeError::InvalidAuthTrailerLength)?;
447 if marker != DISCOVERY_AUTH_MARKER {
448 return Err(DatagramDecodeError::InvalidAuthTrailerLength);
449 }
450
451 let version = *trailer
452 .get(2)
453 .ok_or(DatagramDecodeError::InvalidAuthTrailerLength)?;
454 if version != DISCOVERY_AUTH_PROTOCOL_VERSION {
455 return Err(DatagramDecodeError::UnsupportedAuthProtocolVersion(version));
456 }
457
458 let timestamp_bytes: [u8; 8] = trailer
459 .get(3..11)
460 .ok_or(DatagramDecodeError::InvalidAuthTrailerLength)?
461 .try_into()
462 .map_err(|_| DatagramDecodeError::InvalidAuthTrailerLength)?;
463 let timestamp_secs = u64::from_le_bytes(timestamp_bytes);
464 if !timestamp_in_window(timestamp_secs) {
465 return Err(DatagramDecodeError::AuthTimestampOutsideWindow);
466 }
467
468 let nonce_bytes: [u8; 16] = trailer
469 .get(11..27)
470 .ok_or(DatagramDecodeError::InvalidAuthTrailerLength)?
471 .try_into()
472 .map_err(|_| DatagramDecodeError::InvalidAuthTrailerLength)?;
473 let nonce = u128::from_le_bytes(nonce_bytes);
474 if let Some(cache) = replay_cache {
475 if !cache.check_and_insert(nonce) {
476 return Err(DatagramDecodeError::ReplayNonceDetected);
477 }
478 }
479
480 let received_tag = trailer
481 .get(27..)
482 .ok_or(DatagramDecodeError::InvalidAuthTrailerLength)?;
483 verify_auth_tag_v2(frame_bytes, token, timestamp_secs, nonce, received_tag)?;
484
485 DiscoveryFrame::decode(frame_bytes).map_err(DatagramDecodeError::FrameDecode)
486}
487
488fn decode_legacy_crc32_checksum_datagram(
489 datagram: &[u8],
490 token: &str,
491) -> Result<DiscoveryFrame, DatagramDecodeError> {
492 if datagram.len() < 12 {
493 return Err(DatagramDecodeError::DatagramTooShortForAuth);
494 }
495
496 let (frame_bytes, tag_bytes) = datagram.split_at(datagram.len() - 4);
497 let expected = legacy_crc32_checksum_tag(frame_bytes, token);
498 let received = u32::from_le_bytes(
499 tag_bytes
500 .try_into()
501 .map_err(|_| DatagramDecodeError::InvalidAuthTrailerLength)?,
502 );
503 if expected != received {
504 return Err(DatagramDecodeError::AuthTagMismatch);
505 }
506 DiscoveryFrame::decode(frame_bytes).map_err(DatagramDecodeError::FrameDecode)
507}
508
509fn legacy_crc32_checksum_tag(frame_bytes: &[u8], token: &str) -> u32 {
510 let mut hasher = Hasher::new();
513 hasher.update(frame_bytes);
514 hasher.update(token.as_bytes());
515 hasher.finalize()
516}
517
518fn verify_auth_tag_v2(
519 frame_bytes: &[u8],
520 token: &str,
521 timestamp_secs: u64,
522 nonce: u128,
523 received_tag: &[u8],
524) -> Result<(), DatagramDecodeError> {
525 let mut mac = HmacSha256::new_from_slice(token.as_bytes())
526 .expect("HMAC key setup for discovery auth failed");
527 mac.update(frame_bytes);
528 mac.update(&DISCOVERY_AUTH_MARKER);
529 mac.update(&[DISCOVERY_AUTH_PROTOCOL_VERSION]);
530 mac.update(×tamp_secs.to_le_bytes());
531 mac.update(&nonce.to_le_bytes());
532 mac.verify_slice(received_tag)
533 .map_err(|_| DatagramDecodeError::AuthTagMismatch)
534}
535
536fn auth_tag_v2(frame_bytes: &[u8], token: &str, timestamp_secs: u64, nonce: u128) -> [u8; 32] {
537 let mut mac = HmacSha256::new_from_slice(token.as_bytes())
538 .expect("HMAC key setup for discovery auth failed");
539 mac.update(frame_bytes);
540 mac.update(&DISCOVERY_AUTH_MARKER);
541 mac.update(&[DISCOVERY_AUTH_PROTOCOL_VERSION]);
542 mac.update(×tamp_secs.to_le_bytes());
543 mac.update(&nonce.to_le_bytes());
544 mac.finalize().into_bytes().into()
545}
546
547fn current_unix_timestamp_secs() -> u64 {
548 SystemTime::now()
549 .duration_since(UNIX_EPOCH)
550 .map(|d| d.as_secs())
551 .unwrap_or(0)
552}
553
554fn timestamp_in_window(timestamp_secs: u64) -> bool {
555 let now = current_unix_timestamp_secs();
556 if now >= timestamp_secs {
557 now - timestamp_secs <= DISCOVERY_AUTH_MAX_SKEW_SECS
558 } else {
559 timestamp_secs - now <= DISCOVERY_AUTH_MAX_SKEW_SECS
560 }
561}
562
563fn random_nonce() -> u128 {
564 let mut bytes = [0_u8; 16];
565 OsRng.fill_bytes(&mut bytes);
566 u128::from_le_bytes(bytes)
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572 use crate::protocol::{FrameKind, NodeResources};
573 use std::thread;
574
575 #[test]
576 fn broadcast_fallback_can_send_without_responses() {
577 let sink = UdpSocket::bind("127.0.0.1:0").expect("bind sink");
581 let port = sink.local_addr().expect("local addr").port();
582
583 let frame = DiscoveryFrame {
584 kind: FrameKind::Join,
585 node: NodeResources::new("node-a", 24.0, 64.0, "8.9", None),
586 };
587
588 let config = UdpDiscoveryConfig {
589 bind_addr: SocketAddr::from(([127, 0, 0, 1], 0)),
590 broadcast_addr: SocketAddr::from(([127, 0, 0, 1], port)),
591 response_timeout: Duration::from_millis(10),
592 ..UdpDiscoveryConfig::default()
593 };
594
595 let result = broadcast_and_collect(&frame, &config);
596 drop(sink);
597 assert!(result.is_ok());
598 }
599
600 #[test]
601 fn enforce_auth_without_token_is_rejected_as_config_error() {
602 let config = UdpDiscoveryConfig {
608 bind_addr: SocketAddr::from(([127, 0, 0, 1], 0)),
609 enforce_auth: true,
610 auth_token: None,
611 ..UdpDiscoveryConfig::default()
612 };
613
614 let frame = DiscoveryFrame {
615 kind: FrameKind::Join,
616 node: NodeResources::new("node-config-error", 8.0, 16.0, "8.0", None),
617 };
618 assert!(broadcast_and_collect(&frame, &config).is_err());
619
620 let node = NodeResources::new("node-config-error-listener", 8.0, 16.0, "8.0", None);
621 assert!(respond_once(&node, &config).is_err());
622 assert!(serve_discovery(&node, &config, Some(1)).is_err());
623 }
624
625 #[test]
626 fn datagram_auth_validation_rejects_modified_data() {
627 let frame = DiscoveryFrame {
628 kind: FrameKind::Join,
629 node: NodeResources::new("node-b", 12.0, 32.0, "8.6", None),
630 };
631
632 let mut packet = encode_datagram(&frame, Some("secret"));
633 packet[3] ^= 0x1;
634
635 let result = decode_datagram(&packet, Some("secret"));
636 assert!(result.is_err());
637 }
638
639 #[test]
640 fn responder_replies_to_join_request() {
641 let listener = UdpSocket::bind("127.0.0.1:0").expect("bind temp listener");
642 let port = listener.local_addr().expect("local addr").port();
643 drop(listener);
644
645 let responder_config = UdpDiscoveryConfig {
646 bind_addr: SocketAddr::from(([127, 0, 0, 1], port)),
647 broadcast_addr: SocketAddr::from(([127, 0, 0, 1], port)),
648 response_timeout: Duration::from_millis(400),
649 auth_token: Some("token".to_string()),
650 ..UdpDiscoveryConfig::default()
651 };
652
653 let sender_config = UdpDiscoveryConfig {
654 bind_addr: SocketAddr::from(([127, 0, 0, 1], 0)),
655 broadcast_addr: SocketAddr::from(([127, 0, 0, 1], port)),
656 response_timeout: Duration::from_millis(400),
657 auth_token: Some("token".to_string()),
658 ..UdpDiscoveryConfig::default()
659 };
660
661 let responder_cfg = responder_config.clone();
662 let handle = thread::spawn(move || {
663 let node = NodeResources::new("node-listener", 16.0, 32.0, "8.6", None);
664 respond_once(&node, &responder_cfg)
665 });
666
667 thread::sleep(Duration::from_millis(200));
673
674 let join = DiscoveryFrame {
675 kind: FrameKind::Join,
676 node: NodeResources::new("node-sender", 12.0, 32.0, "8.0", None),
677 };
678 let replies =
679 broadcast_and_collect(&join, &sender_config).expect("broadcast should succeed");
680 assert!(!replies.is_empty());
681 assert!(replies.iter().any(
682 |(reply, _)| reply.node.id == "node-listener" && reply.kind == FrameKind::Discovery
683 ));
684
685 let responded = handle
686 .join()
687 .expect("responder thread join")
688 .expect("responder should return result");
689 assert!(responded.is_some());
690 }
691
692 #[test]
693 fn respond_once_ignores_auth_mismatch_then_accepts_valid_request() {
694 let listener = UdpSocket::bind("127.0.0.1:0").expect("bind temp listener");
695 let port = listener.local_addr().expect("local addr").port();
696 drop(listener);
697
698 let responder_config = UdpDiscoveryConfig {
699 bind_addr: SocketAddr::from(([127, 0, 0, 1], port)),
700 response_timeout: Duration::from_millis(700),
701 auth_token: Some("secret".to_string()),
702 ..UdpDiscoveryConfig::default()
703 };
704
705 let responder_cfg = responder_config.clone();
706 let handle = thread::spawn(move || {
707 let node = NodeResources::new("node-listener", 16.0, 32.0, "8.6", None);
708 respond_once(&node, &responder_cfg)
709 });
710
711 thread::sleep(Duration::from_millis(200));
717
718 let mismatch_sender = UdpDiscoveryConfig {
719 bind_addr: SocketAddr::from(([127, 0, 0, 1], 0)),
720 broadcast_addr: SocketAddr::from(([127, 0, 0, 1], port)),
721 response_timeout: Duration::from_millis(100),
722 auth_token: Some("wrong".to_string()),
723 ..UdpDiscoveryConfig::default()
724 };
725 let valid_sender = UdpDiscoveryConfig {
726 bind_addr: SocketAddr::from(([127, 0, 0, 1], 0)),
727 broadcast_addr: SocketAddr::from(([127, 0, 0, 1], port)),
728 response_timeout: Duration::from_millis(250),
729 auth_token: Some("secret".to_string()),
730 ..UdpDiscoveryConfig::default()
731 };
732
733 let join = DiscoveryFrame {
734 kind: FrameKind::Join,
735 node: NodeResources::new("node-sender", 12.0, 32.0, "8.0", None),
736 };
737
738 let mismatch_replies =
739 broadcast_and_collect(&join, &mismatch_sender).expect("mismatch send should succeed");
740 assert!(mismatch_replies.is_empty());
741
742 let valid_replies =
743 broadcast_and_collect(&join, &valid_sender).expect("valid send should succeed");
744 assert!(valid_replies
745 .iter()
746 .any(|(reply, _)| reply.node.id == "node-listener"));
747
748 let responded = handle
749 .join()
750 .expect("responder thread join")
751 .expect("responder should return result");
752 assert!(responded.is_some());
753 }
754
755 #[test]
756 fn broadcast_collects_multiple_replies_in_single_window() {
757 let listener = UdpSocket::bind("127.0.0.1:0").expect("bind temp listener");
758 let port = listener.local_addr().expect("local addr").port();
759 drop(listener);
760
761 let responder = thread::spawn(move || {
762 let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], port)))
763 .expect("bind burst responder");
764 socket
765 .set_read_timeout(Some(Duration::from_millis(700)))
766 .expect("set timeout");
767
768 let mut recv_buf = vec![0_u8; 1024];
769 let (read, peer_addr) = socket.recv_from(&mut recv_buf).expect("recv request");
770 let incoming = decode_datagram(&recv_buf[..read], Some("secret"))
771 .expect("decode incoming datagram");
772 assert_eq!(incoming.kind, FrameKind::Join);
773
774 for node_id in ["node-a", "node-b"] {
775 let reply = DiscoveryFrame {
776 kind: FrameKind::Discovery,
777 node: NodeResources::new(node_id, 16.0, 32.0, "8.6", None),
778 };
779 let packet = encode_datagram(&reply, Some("secret"));
780 socket
781 .send_to(&packet, peer_addr)
782 .expect("send reply datagram");
783 }
784 });
785
786 thread::sleep(Duration::from_millis(200));
792
793 let sender = UdpDiscoveryConfig {
794 bind_addr: SocketAddr::from(([127, 0, 0, 1], 0)),
795 broadcast_addr: SocketAddr::from(([127, 0, 0, 1], port)),
796 response_timeout: Duration::from_millis(250),
797 auth_token: Some("secret".to_string()),
798 ..UdpDiscoveryConfig::default()
799 };
800 let join = DiscoveryFrame {
801 kind: FrameKind::Join,
802 node: NodeResources::new("node-sender", 12.0, 24.0, "8.0", None),
803 };
804
805 let replies = broadcast_and_collect(&join, &sender).expect("broadcast should succeed");
806 assert!(replies.iter().any(|(reply, _)| reply.node.id == "node-a"));
807 assert!(replies.iter().any(|(reply, _)| reply.node.id == "node-b"));
808
809 responder.join().expect("responder join");
810 }
811
812 #[test]
813 fn serve_discovery_with_stats_tracks_drop_reasons() {
814 let listener = UdpSocket::bind("127.0.0.1:0").expect("bind temp listener");
815 let port = listener.local_addr().expect("local addr").port();
816 drop(listener);
817
818 let responder_config = UdpDiscoveryConfig {
819 bind_addr: SocketAddr::from(([127, 0, 0, 1], port)),
820 response_timeout: Duration::from_millis(1500),
821 auth_token: Some("secret".to_string()),
822 ..UdpDiscoveryConfig::default()
823 };
824
825 let responder_cfg = responder_config.clone();
826 let handle = thread::spawn(move || {
827 let node = NodeResources::new("node-listener", 16.0, 32.0, "8.6", None);
828 serve_discovery_with_stats(&node, &responder_cfg, Some(1))
829 });
830
831 thread::sleep(Duration::from_millis(200));
837
838 let raw_sender =
839 UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).expect("bind raw sender");
840 raw_sender
841 .send_to(
842 &[0xaa, 0xbb, 0xcc],
843 SocketAddr::from(([127, 0, 0, 1], port)),
844 )
845 .expect("send malformed datagram");
846
847 let mismatch_sender = UdpDiscoveryConfig {
848 bind_addr: SocketAddr::from(([127, 0, 0, 1], 0)),
849 broadcast_addr: SocketAddr::from(([127, 0, 0, 1], port)),
850 response_timeout: Duration::from_millis(120),
851 auth_token: Some("wrong".to_string()),
852 ..UdpDiscoveryConfig::default()
853 };
854 let valid_sender = UdpDiscoveryConfig {
855 bind_addr: SocketAddr::from(([127, 0, 0, 1], 0)),
856 broadcast_addr: SocketAddr::from(([127, 0, 0, 1], port)),
857 response_timeout: Duration::from_millis(200),
858 auth_token: Some("secret".to_string()),
859 ..UdpDiscoveryConfig::default()
860 };
861
862 let mismatch_join = DiscoveryFrame {
863 kind: FrameKind::Join,
864 node: NodeResources::new("node-mismatch", 8.0, 16.0, "8.0", None),
865 };
866 let unsupported = DiscoveryFrame {
867 kind: FrameKind::Attestation,
868 node: NodeResources::new("node-unsupported", 8.0, 16.0, "8.0", None),
869 };
870 let valid_join = DiscoveryFrame {
871 kind: FrameKind::Join,
872 node: NodeResources::new("node-valid", 8.0, 16.0, "8.0", None),
873 };
874
875 let mismatch_replies =
876 broadcast_and_collect(&mismatch_join, &mismatch_sender).expect("send mismatch join");
877 assert!(mismatch_replies.is_empty());
878
879 let unsupported_replies =
880 broadcast_and_collect(&unsupported, &valid_sender).expect("send unsupported frame");
881 assert!(unsupported_replies.is_empty());
882
883 let valid_replies = broadcast_and_collect(&valid_join, &valid_sender).expect("send join");
884 assert!(!valid_replies.is_empty());
885
886 let stats = handle
887 .join()
888 .expect("responder thread join")
889 .expect("responder should return result");
890
891 assert_eq!(stats.replies_sent, 1);
892 assert!(stats.drops.malformed >= 1);
893 assert!(stats.drops.auth_mismatch >= 1);
894 assert!(stats.drops.unsupported_kind >= 1);
895 }
896
897 #[test]
898 fn respond_once_survives_mixed_traffic_soak_before_valid_join() {
899 let listener = UdpSocket::bind("127.0.0.1:0").expect("bind temp listener");
900 let port = listener.local_addr().expect("local addr").port();
901 drop(listener);
902
903 let responder_config = UdpDiscoveryConfig {
904 bind_addr: SocketAddr::from(([127, 0, 0, 1], port)),
905 response_timeout: Duration::from_millis(6000),
906 auth_token: Some("secret".to_string()),
907 ..UdpDiscoveryConfig::default()
908 };
909
910 let responder_cfg = responder_config.clone();
911 let handle = thread::spawn(move || {
912 let node = NodeResources::new("node-listener", 16.0, 32.0, "8.6", None);
913 respond_once(&node, &responder_cfg)
914 });
915
916 thread::sleep(Duration::from_millis(200));
922
923 let raw_sender =
924 UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).expect("bind raw sender");
925 for _ in 0..24 {
926 raw_sender
927 .send_to(&[0xde, 0xad], SocketAddr::from(([127, 0, 0, 1], port)))
928 .expect("send malformed datagram");
929 }
930
931 let wrong_sender = UdpDiscoveryConfig {
932 bind_addr: SocketAddr::from(([127, 0, 0, 1], 0)),
933 broadcast_addr: SocketAddr::from(([127, 0, 0, 1], port)),
934 response_timeout: Duration::from_millis(150),
939 auth_token: Some("wrong".to_string()),
940 ..UdpDiscoveryConfig::default()
941 };
942 let valid_sender = UdpDiscoveryConfig {
943 bind_addr: SocketAddr::from(([127, 0, 0, 1], 0)),
944 broadcast_addr: SocketAddr::from(([127, 0, 0, 1], port)),
945 response_timeout: Duration::from_millis(500),
948 auth_token: Some("secret".to_string()),
949 ..UdpDiscoveryConfig::default()
950 };
951
952 let wrong_join = DiscoveryFrame {
953 kind: FrameKind::Join,
954 node: NodeResources::new("node-wrong", 8.0, 16.0, "8.0", None),
955 };
956 for _ in 0..4 {
957 let replies =
958 broadcast_and_collect(&wrong_join, &wrong_sender).expect("send wrong join frame");
959 assert!(replies.is_empty());
960 }
961
962 let valid_join = DiscoveryFrame {
963 kind: FrameKind::Join,
964 node: NodeResources::new("node-valid", 8.0, 16.0, "8.0", None),
965 };
966 let valid_replies =
967 broadcast_and_collect(&valid_join, &valid_sender).expect("send valid join frame");
968 assert!(valid_replies
969 .iter()
970 .any(|(reply, _)| reply.node.id == "node-listener"));
971
972 let responded = handle
973 .join()
974 .expect("responder thread join")
975 .expect("responder should return result");
976 assert!(responded.is_some());
977 }
978}