Skip to main content

ghostlink_core/
discovery.rs

1//! UDP discovery fallback for mixed LAN environments.
2//!
3//! This module provides a lightweight broadcast path that can coexist with
4//! raw L2 discovery. Frames retain the same binary payload format.
5
6use 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
103/// Default UDP discovery port.
104pub const DEFAULT_DISCOVERY_PORT: u16 = 45885;
105
106/// UDP fallback configuration for discovery broadcast and reply collection.
107#[derive(Clone, Debug)]
108pub struct UdpDiscoveryConfig {
109    /// Local bind address used for sending and receiving replies.
110    pub bind_addr: SocketAddr,
111    /// Target broadcast address for join/discovery datagrams.
112    pub broadcast_addr: SocketAddr,
113    /// How long to wait for discovery responses.
114    pub response_timeout: Duration,
115    /// Optional shared token used to authenticate datagrams.
116    pub auth_token: Option<String>,
117    /// Require HMAC-SHA256 authentication for all incoming datagrams.
118    pub enforce_auth: bool,
119    /// Allow decode fallback to legacy CRC32 auth tags during migration.
120    pub allow_legacy_crc32: bool,
121    /// Maximum datagram size expected during receive.
122    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
139/// Broadcast a discovery frame over UDP and collect any valid replies with their source addresses.
140pub 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                // If enforce_auth is true but no token is provided, this is a config error.
177                // In secure mode, we only accept authenticated frames from peers.
178                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
210/// Listen for one discovery request and reply with local node resources.
211///
212/// Returns the peer socket address that was replied to when a valid request is
213/// received before timeout, or `None` when the wait timed out.
214pub 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
294/// Serve discovery requests and respond with local node resources.
295///
296/// When `max_replies` is `Some(n)`, the function returns after `n` successful
297/// replies. When `None`, it runs indefinitely.
298pub 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(&timestamp_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    // Compatibility-only checksum mode for migration windows.
511    // This is not a cryptographic MAC and should not be treated as strong auth.
512    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(&timestamp_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(&timestamp_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        // Bind a sink socket on loopback so send_to succeeds without requiring a
578        // broadcast-capable physical interface (which may be absent on CI runners,
579        // especially macOS, where sending to 255.255.255.255 returns EHOSTUNREACH).
580        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        // enforce_auth=true with no auth_token previously fell through to the
603        // unauthenticated decode path, silently accepting any well-formed frame
604        // with zero authentication. That contradicts the documented contract
605        // ("Require HMAC-SHA256 authentication for all incoming datagrams") and
606        // must fail closed instead.
607        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        // Give the responder thread time to actually reach its socket recv
668        // loop before traffic starts. 50ms was observed to be too tight under
669        // system load (e.g. `cargo test --workspace` scheduling many
670        // concurrent tests/processes), intermittently causing early packets
671        // to arrive before the responder was listening.
672        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        // Give the responder thread time to actually reach its socket recv
712        // loop before traffic starts. 50ms was observed to be too tight under
713        // system load (e.g. `cargo test --workspace` scheduling many
714        // concurrent tests/processes), intermittently causing early packets
715        // to arrive before the responder was listening.
716        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        // Give the responder thread time to actually reach its socket recv
787        // loop before traffic starts. 50ms was observed to be too tight under
788        // system load (e.g. `cargo test --workspace` scheduling many
789        // concurrent tests/processes), intermittently causing early packets
790        // to arrive before the responder was listening.
791        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        // Give the responder thread time to actually reach its socket recv
832        // loop before traffic starts. 50ms was observed to be too tight under
833        // system load (e.g. `cargo test --workspace` scheduling many
834        // concurrent tests/processes), intermittently causing early packets
835        // to arrive before the responder was listening.
836        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        // Give the responder thread time to actually reach its socket recv
917        // loop before traffic starts. 50ms was observed to be too tight under
918        // system load (e.g. `cargo test --workspace` scheduling many
919        // concurrent tests/processes), intermittently causing early packets
920        // to arrive before the responder was listening.
921        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            // Was 45ms; bumped for headroom under system load. This waits
935            // out the full timeout each call (no reply is expected for a
936            // bad-auth frame), so a tighter value only saved wall-clock time
937            // in the success case at the cost of false failures under load.
938            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            // Was 260ms; bumped alongside the startup sleep/wrong_sender
946            // timeout above for the same load-headroom reason.
947            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}