Skip to main content

ipfrs_network/
nat_traversal.rs

1//! NAT traversal system for P2P hole-punching.
2//!
3//! Provides NAT type detection, STUN-like binding analysis,
4//! hole-punch coordination with port prediction, and relay fallback.
5
6use std::collections::HashMap;
7
8/// NAT type detection result
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum NatType {
11    /// No NAT — public IP directly reachable
12    Open,
13    /// Full cone: any external host can send to mapped port
14    FullCone,
15    /// Restricted cone: only hosts the internal host has sent to
16    RestrictedCone,
17    /// Port restricted: restricted by both IP and port
18    PortRestricted,
19    /// Symmetric: different mapping per destination
20    Symmetric,
21    /// Could not determine NAT type
22    Unknown,
23}
24
25/// Traversal strategy recommendation
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum TraversalStrategy {
28    /// Open NAT — direct connection
29    Direct,
30    /// Full cone / restricted — simultaneous hole punch
31    HolePunch,
32    /// Port restricted with predictable pattern — port prediction + punch
33    PortPrediction,
34    /// Symmetric — needs relay
35    Relay,
36}
37
38/// Status of a hole-punch attempt
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum HolePunchStatus {
41    /// Waiting to start
42    Pending,
43    /// Currently attempting
44    InProgress,
45    /// Successfully established
46    Success,
47    /// All attempts exhausted
48    Failed,
49    /// Exceeded time limit
50    TimedOut,
51}
52
53/// STUN-like binding result
54#[derive(Debug, Clone)]
55pub struct StunBinding {
56    /// Local address used for the binding request
57    pub local_addr: String,
58    /// Mapped (external) address as seen by the STUN server
59    pub mapped_addr: String,
60    /// Detected NAT type from this binding
61    pub nat_type: NatType,
62    /// Round-trip latency in milliseconds
63    pub latency_ms: u64,
64    /// Unix timestamp when this binding was obtained
65    pub timestamp: u64,
66}
67
68/// Tracks a single hole-punch attempt to a peer
69#[derive(Debug, Clone)]
70pub struct HolePunchAttempt {
71    /// Peer identifier
72    pub peer_id: String,
73    /// Local port used for punching
74    pub local_port: u16,
75    /// Remote address to punch towards
76    pub remote_addr: String,
77    /// Number of attempts made so far
78    pub attempts: u32,
79    /// Maximum attempts before giving up
80    pub max_attempts: u32,
81    /// Current status
82    pub status: HolePunchStatus,
83    /// Unix timestamp when the attempt started
84    pub started_at: u64,
85}
86
87/// Configuration for NAT traversal behaviour
88#[derive(Debug, Clone)]
89pub struct NatTraversalConfig {
90    /// Timeout for STUN binding requests (ms)
91    pub stun_timeout_ms: u64,
92    /// Maximum hole-punch attempts per peer
93    pub max_punch_attempts: u32,
94    /// Interval between successive punch packets (ms)
95    pub punch_interval_ms: u64,
96    /// Range of ports to scan around a predicted port
97    pub port_prediction_range: u16,
98    /// Whether to attempt UPnP port mapping
99    pub enable_upnp: bool,
100    /// Whether to fall back to relay when punch fails
101    pub enable_relay_fallback: bool,
102}
103
104impl Default for NatTraversalConfig {
105    fn default() -> Self {
106        Self {
107            stun_timeout_ms: 3000,
108            max_punch_attempts: 5,
109            punch_interval_ms: 500,
110            port_prediction_range: 10,
111            enable_upnp: true,
112            enable_relay_fallback: true,
113        }
114    }
115}
116
117/// Aggregated statistics for NAT traversal operations
118#[derive(Debug, Clone, Default)]
119pub struct NatTraversalStats {
120    /// Total hole-punch attempts initiated
121    pub total_attempts: u64,
122    /// Successful punches
123    pub successful: u64,
124    /// Failed punches (max attempts exhausted)
125    pub failed: u64,
126    /// Punches that timed out
127    pub timed_out: u64,
128    /// Running average of successful punch time (ms)
129    pub avg_punch_time_ms: f64,
130}
131
132/// Central manager for NAT traversal operations
133pub struct NatTraversalManager {
134    config: NatTraversalConfig,
135    bindings: Vec<StunBinding>,
136    active_punches: HashMap<String, HolePunchAttempt>,
137    stats: NatTraversalStats,
138    detected_nat_type: NatType,
139}
140
141impl NatTraversalManager {
142    /// Create a new manager with the given configuration.
143    pub fn new(config: NatTraversalConfig) -> Self {
144        Self {
145            config,
146            bindings: Vec::new(),
147            active_punches: HashMap::new(),
148            stats: NatTraversalStats::default(),
149            detected_nat_type: NatType::Unknown,
150        }
151    }
152
153    /// Analyse a set of STUN bindings to determine the NAT type.
154    ///
155    /// The algorithm compares mapped addresses across bindings:
156    /// - All identical mapped addresses => FullCone (or Open if mapped == local)
157    /// - Same IP but different ports => PortRestricted
158    /// - Different IPs => Symmetric
159    /// - Single binding or ambiguous => RestrictedCone or Unknown
160    pub fn detect_nat_type(bindings: &[StunBinding]) -> NatType {
161        if bindings.is_empty() {
162            return NatType::Unknown;
163        }
164
165        if bindings.len() == 1 {
166            let b = &bindings[0];
167            if b.local_addr == b.mapped_addr {
168                return NatType::Open;
169            }
170            // Single binding — can only say it's behind some NAT
171            return NatType::RestrictedCone;
172        }
173
174        // Extract mapped IPs and ports
175        let mapped_parts: Vec<(&str, &str)> = bindings
176            .iter()
177            .filter_map(|b| {
178                let parts: Vec<&str> = b.mapped_addr.rsplitn(2, ':').collect();
179                if parts.len() == 2 {
180                    Some((parts[1], parts[0]))
181                } else {
182                    None
183                }
184            })
185            .collect();
186
187        if mapped_parts.is_empty() {
188            return NatType::Unknown;
189        }
190
191        // Check if all local == mapped (Open)
192        let all_open = bindings.iter().all(|b| b.local_addr == b.mapped_addr);
193        if all_open {
194            return NatType::Open;
195        }
196
197        let first_ip = mapped_parts[0].0;
198        let first_port = mapped_parts[0].1;
199
200        let all_same_ip = mapped_parts.iter().all(|(ip, _)| *ip == first_ip);
201        let all_same_port = mapped_parts.iter().all(|(_, port)| *port == first_port);
202
203        if all_same_ip && all_same_port {
204            // Same external endpoint regardless of destination => FullCone
205            NatType::FullCone
206        } else if all_same_ip && !all_same_port {
207            // Same IP but varying port => PortRestricted
208            NatType::PortRestricted
209        } else {
210            // Different IPs across destinations => Symmetric
211            NatType::Symmetric
212        }
213    }
214
215    /// Record a new STUN binding and re-detect the NAT type.
216    pub fn add_binding(&mut self, binding: StunBinding) {
217        self.bindings.push(binding);
218        self.detected_nat_type = Self::detect_nat_type(&self.bindings);
219    }
220
221    /// Begin a hole-punch attempt to a peer.
222    ///
223    /// Returns an error if a punch is already active for this peer.
224    pub fn initiate_punch(
225        &mut self,
226        peer_id: &str,
227        remote_addr: &str,
228        local_port: u16,
229    ) -> Result<(), String> {
230        if self.active_punches.contains_key(peer_id) {
231            return Err(format!("Hole-punch already active for peer {}", peer_id));
232        }
233
234        let attempt = HolePunchAttempt {
235            peer_id: peer_id.to_string(),
236            local_port,
237            remote_addr: remote_addr.to_string(),
238            attempts: 0,
239            max_attempts: self.config.max_punch_attempts,
240            status: HolePunchStatus::Pending,
241            started_at: current_timestamp(),
242        };
243
244        self.active_punches.insert(peer_id.to_string(), attempt);
245        self.stats.total_attempts += 1;
246
247        Ok(())
248    }
249
250    /// Predict the next allocated port(s) based on observed samples.
251    ///
252    /// If the samples show a linear pattern (constant delta), predict the next
253    /// port and return a range around it. Otherwise return a range around the
254    /// base port.
255    pub fn predict_port(&self, base_port: u16, sample_ports: &[u16]) -> Vec<u16> {
256        let range = self.config.port_prediction_range;
257
258        if sample_ports.len() >= 2 {
259            // Calculate deltas between consecutive ports
260            let deltas: Vec<i32> = sample_ports
261                .windows(2)
262                .map(|w| i32::from(w[1]) - i32::from(w[0]))
263                .collect();
264
265            // Check if deltas are consistent (linear allocation)
266            let first_delta = deltas[0];
267            let is_linear = deltas.iter().all(|d| *d == first_delta);
268
269            if is_linear && first_delta != 0 {
270                let last = i32::from(*sample_ports.last().unwrap_or(&base_port));
271                let predicted = last + first_delta;
272                let center = predicted.clamp(0, 65535) as u16;
273                return port_range(center, range);
274            }
275        }
276
277        // Fallback: range around base_port
278        port_range(base_port, range)
279    }
280
281    /// Update the status of an active hole-punch attempt.
282    pub fn update_attempt(&mut self, peer_id: &str, status: HolePunchStatus) -> Result<(), String> {
283        let attempt = self
284            .active_punches
285            .get_mut(peer_id)
286            .ok_or_else(|| format!("No active punch for peer {}", peer_id))?;
287
288        match &status {
289            HolePunchStatus::InProgress => {
290                attempt.attempts += 1;
291                if attempt.attempts > attempt.max_attempts {
292                    attempt.status = HolePunchStatus::Failed;
293                    self.stats.failed += 1;
294                    return Ok(());
295                }
296            }
297            HolePunchStatus::Success => {
298                self.stats.successful += 1;
299                let elapsed = current_timestamp().saturating_sub(attempt.started_at);
300                // Update running average
301                let n = self.stats.successful as f64;
302                self.stats.avg_punch_time_ms =
303                    self.stats.avg_punch_time_ms * ((n - 1.0) / n) + (elapsed as f64) / n;
304            }
305            HolePunchStatus::Failed => {
306                self.stats.failed += 1;
307            }
308            HolePunchStatus::TimedOut => {
309                self.stats.timed_out += 1;
310            }
311            HolePunchStatus::Pending => {}
312        }
313
314        attempt.status = status;
315        Ok(())
316    }
317
318    /// Look up an active punch attempt by peer ID.
319    pub fn get_attempt(&self, peer_id: &str) -> Option<&HolePunchAttempt> {
320        self.active_punches.get(peer_id)
321    }
322
323    /// Number of currently active (non-terminal) punch attempts.
324    pub fn active_punch_count(&self) -> usize {
325        self.active_punches
326            .values()
327            .filter(|a| {
328                matches!(
329                    a.status,
330                    HolePunchStatus::Pending | HolePunchStatus::InProgress
331                )
332            })
333            .count()
334    }
335
336    /// Remove expired or completed attempts older than `now - stun_timeout_ms`.
337    /// Returns the number of entries removed.
338    pub fn cleanup_expired(&mut self, now: u64) -> usize {
339        let timeout = self.config.stun_timeout_ms;
340        let before = self.active_punches.len();
341
342        self.active_punches.retain(|_k, v| {
343            let age = now.saturating_sub(v.started_at);
344            let is_terminal = matches!(
345                v.status,
346                HolePunchStatus::Success | HolePunchStatus::Failed | HolePunchStatus::TimedOut
347            );
348            // Keep if not expired AND not in a terminal state
349            age < timeout || !is_terminal
350        });
351
352        before - self.active_punches.len()
353    }
354
355    /// Fraction of successful punches out of total (0.0 if none attempted).
356    pub fn success_rate(&self) -> f64 {
357        if self.stats.total_attempts == 0 {
358            return 0.0;
359        }
360        self.stats.successful as f64 / self.stats.total_attempts as f64
361    }
362
363    /// Reference to aggregated stats.
364    pub fn stats(&self) -> &NatTraversalStats {
365        &self.stats
366    }
367
368    /// Reference to the currently detected NAT type.
369    pub fn detected_nat_type(&self) -> &NatType {
370        &self.detected_nat_type
371    }
372
373    /// Recommend the best traversal strategy based on the detected NAT type.
374    pub fn best_strategy(&self) -> TraversalStrategy {
375        match &self.detected_nat_type {
376            NatType::Open => TraversalStrategy::Direct,
377            NatType::FullCone | NatType::RestrictedCone => TraversalStrategy::HolePunch,
378            NatType::PortRestricted => TraversalStrategy::PortPrediction,
379            NatType::Symmetric | NatType::Unknown => {
380                if self.config.enable_relay_fallback {
381                    TraversalStrategy::Relay
382                } else {
383                    TraversalStrategy::HolePunch
384                }
385            }
386        }
387    }
388}
389
390// ---------------------------------------------------------------------------
391// Helpers
392// ---------------------------------------------------------------------------
393
394/// Generate a sorted, deduplicated range of ports around `center`.
395fn port_range(center: u16, half_range: u16) -> Vec<u16> {
396    let low = center.saturating_sub(half_range);
397    let high = center.saturating_add(half_range);
398    (low..=high).collect()
399}
400
401/// Monotonic-ish timestamp in milliseconds (uses `Instant` elapsed from a
402/// fixed epoch for testability). Falls back to 0 on platforms without a clock.
403fn current_timestamp() -> u64 {
404    use std::time::{SystemTime, UNIX_EPOCH};
405    SystemTime::now()
406        .duration_since(UNIX_EPOCH)
407        .unwrap_or_default()
408        .as_millis() as u64
409}
410
411// ---------------------------------------------------------------------------
412// Tests
413// ---------------------------------------------------------------------------
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    fn default_config() -> NatTraversalConfig {
420        NatTraversalConfig::default()
421    }
422
423    fn make_binding(local: &str, mapped: &str) -> StunBinding {
424        StunBinding {
425            local_addr: local.to_string(),
426            mapped_addr: mapped.to_string(),
427            nat_type: NatType::Unknown,
428            latency_ms: 10,
429            timestamp: 1000,
430        }
431    }
432
433    // ---- Config defaults ----
434
435    #[test]
436    fn config_default_values() {
437        let c = NatTraversalConfig::default();
438        assert_eq!(c.stun_timeout_ms, 3000);
439        assert_eq!(c.max_punch_attempts, 5);
440        assert_eq!(c.punch_interval_ms, 500);
441        assert_eq!(c.port_prediction_range, 10);
442        assert!(c.enable_upnp);
443        assert!(c.enable_relay_fallback);
444    }
445
446    #[test]
447    fn config_custom_values() {
448        let c = NatTraversalConfig {
449            stun_timeout_ms: 5000,
450            max_punch_attempts: 10,
451            punch_interval_ms: 200,
452            port_prediction_range: 20,
453            enable_upnp: false,
454            enable_relay_fallback: false,
455        };
456        assert_eq!(c.stun_timeout_ms, 5000);
457        assert!(!c.enable_upnp);
458    }
459
460    // ---- StunBinding creation ----
461
462    #[test]
463    fn stun_binding_fields() {
464        let b = StunBinding {
465            local_addr: "192.168.1.1:5000".into(),
466            mapped_addr: "1.2.3.4:6000".into(),
467            nat_type: NatType::FullCone,
468            latency_ms: 42,
469            timestamp: 999,
470        };
471        assert_eq!(b.local_addr, "192.168.1.1:5000");
472        assert_eq!(b.mapped_addr, "1.2.3.4:6000");
473        assert_eq!(b.nat_type, NatType::FullCone);
474        assert_eq!(b.latency_ms, 42);
475        assert_eq!(b.timestamp, 999);
476    }
477
478    #[test]
479    fn stun_binding_clone() {
480        let b = make_binding("10.0.0.1:80", "1.2.3.4:80");
481        let b2 = b.clone();
482        assert_eq!(b.local_addr, b2.local_addr);
483        assert_eq!(b.mapped_addr, b2.mapped_addr);
484    }
485
486    // ---- NAT type detection ----
487
488    #[test]
489    fn detect_nat_type_empty() {
490        assert_eq!(NatTraversalManager::detect_nat_type(&[]), NatType::Unknown);
491    }
492
493    #[test]
494    fn detect_nat_type_open_single() {
495        let bindings = [make_binding("1.2.3.4:5000", "1.2.3.4:5000")];
496        assert_eq!(
497            NatTraversalManager::detect_nat_type(&bindings),
498            NatType::Open
499        );
500    }
501
502    #[test]
503    fn detect_nat_type_open_multiple() {
504        let bindings = [
505            make_binding("1.2.3.4:5000", "1.2.3.4:5000"),
506            make_binding("1.2.3.4:5001", "1.2.3.4:5001"),
507        ];
508        assert_eq!(
509            NatTraversalManager::detect_nat_type(&bindings),
510            NatType::Open
511        );
512    }
513
514    #[test]
515    fn detect_nat_type_full_cone() {
516        // Same mapped addr regardless of destination
517        let bindings = [
518            make_binding("192.168.1.1:5000", "1.2.3.4:6000"),
519            make_binding("192.168.1.1:5001", "1.2.3.4:6000"),
520        ];
521        assert_eq!(
522            NatTraversalManager::detect_nat_type(&bindings),
523            NatType::FullCone
524        );
525    }
526
527    #[test]
528    fn detect_nat_type_port_restricted() {
529        // Same IP but different ports
530        let bindings = [
531            make_binding("192.168.1.1:5000", "1.2.3.4:6000"),
532            make_binding("192.168.1.1:5001", "1.2.3.4:6001"),
533        ];
534        assert_eq!(
535            NatTraversalManager::detect_nat_type(&bindings),
536            NatType::PortRestricted
537        );
538    }
539
540    #[test]
541    fn detect_nat_type_symmetric() {
542        // Different IPs
543        let bindings = [
544            make_binding("192.168.1.1:5000", "1.2.3.4:6000"),
545            make_binding("192.168.1.1:5001", "5.6.7.8:6001"),
546        ];
547        assert_eq!(
548            NatTraversalManager::detect_nat_type(&bindings),
549            NatType::Symmetric
550        );
551    }
552
553    #[test]
554    fn detect_nat_type_restricted_cone_single_behind_nat() {
555        // Single binding, local != mapped
556        let bindings = [make_binding("192.168.1.1:5000", "1.2.3.4:6000")];
557        assert_eq!(
558            NatTraversalManager::detect_nat_type(&bindings),
559            NatType::RestrictedCone
560        );
561    }
562
563    #[test]
564    fn detect_nat_type_unknown_bad_format() {
565        // Mapped addr without colon separator
566        let bindings = [StunBinding {
567            local_addr: "192.168.1.1".into(),
568            mapped_addr: "no-port-here".into(),
569            nat_type: NatType::Unknown,
570            latency_ms: 0,
571            timestamp: 0,
572        }];
573        // Single binding with no port parse => RestrictedCone (single-binding path)
574        // Actually the filter_map will yield empty => Unknown
575        // But we have len()==1 check first...
576        // The single-binding check runs before mapped_parts extraction.
577        // local != mapped => RestrictedCone
578        assert_eq!(
579            NatTraversalManager::detect_nat_type(&bindings),
580            NatType::RestrictedCone
581        );
582    }
583
584    // ---- add_binding ----
585
586    #[test]
587    fn add_binding_updates_nat_type() {
588        let mut mgr = NatTraversalManager::new(default_config());
589        assert_eq!(*mgr.detected_nat_type(), NatType::Unknown);
590
591        mgr.add_binding(make_binding("192.168.1.1:5000", "1.2.3.4:6000"));
592        // Single behind-NAT binding => RestrictedCone
593        assert_eq!(*mgr.detected_nat_type(), NatType::RestrictedCone);
594
595        mgr.add_binding(make_binding("192.168.1.1:5001", "1.2.3.4:6000"));
596        // Two bindings, same mapped => FullCone
597        assert_eq!(*mgr.detected_nat_type(), NatType::FullCone);
598    }
599
600    // ---- Port prediction ----
601
602    #[test]
603    fn predict_port_linear_pattern() {
604        let mgr = NatTraversalManager::new(default_config());
605        // Ports increasing by 2
606        let predicted = mgr.predict_port(5000, &[5000, 5002, 5004]);
607        // Next predicted: 5006, range ±10
608        assert!(predicted.contains(&5006));
609        assert!(predicted.contains(&4996));
610        assert!(predicted.contains(&5016));
611    }
612
613    #[test]
614    fn predict_port_no_samples() {
615        let mgr = NatTraversalManager::new(default_config());
616        let predicted = mgr.predict_port(8000, &[]);
617        // Fallback: range around base_port
618        assert!(predicted.contains(&8000));
619        assert!(predicted.contains(&7990));
620        assert!(predicted.contains(&8010));
621    }
622
623    #[test]
624    fn predict_port_single_sample() {
625        let mgr = NatTraversalManager::new(default_config());
626        let predicted = mgr.predict_port(4000, &[4000]);
627        // Not enough for pattern => fallback
628        assert!(predicted.contains(&4000));
629    }
630
631    #[test]
632    fn predict_port_random_pattern() {
633        let mgr = NatTraversalManager::new(default_config());
634        // Non-linear deltas
635        let predicted = mgr.predict_port(3000, &[3000, 3005, 3003]);
636        // Fallback to base_port range
637        assert!(predicted.contains(&3000));
638    }
639
640    #[test]
641    fn predict_port_near_upper_bound() {
642        let mgr = NatTraversalManager::new(default_config());
643        let predicted = mgr.predict_port(65530, &[65528, 65530, 65532]);
644        // Predicted: 65534, range should end at 65535
645        assert!(predicted.last().copied().unwrap_or(0) == 65535);
646    }
647
648    #[test]
649    fn predict_port_near_lower_bound() {
650        let mgr = NatTraversalManager::new(default_config());
651        let predicted = mgr.predict_port(5, &[10, 5, 0]);
652        // delta = -5, predicted = 0 - 5 => clamped to 0
653        assert!(predicted.contains(&0));
654    }
655
656    // ---- Hole punch initiation ----
657
658    #[test]
659    fn initiate_punch_success() {
660        let mut mgr = NatTraversalManager::new(default_config());
661        let result = mgr.initiate_punch("peer-1", "1.2.3.4:8000", 5000);
662        assert!(result.is_ok());
663        assert_eq!(mgr.active_punch_count(), 1);
664    }
665
666    #[test]
667    fn initiate_punch_duplicate_error() {
668        let mut mgr = NatTraversalManager::new(default_config());
669        let _ = mgr.initiate_punch("peer-1", "1.2.3.4:8000", 5000);
670        let result = mgr.initiate_punch("peer-1", "1.2.3.4:9000", 6000);
671        assert!(result.is_err());
672    }
673
674    #[test]
675    fn initiate_punch_increments_total_attempts() {
676        let mut mgr = NatTraversalManager::new(default_config());
677        let _ = mgr.initiate_punch("p1", "1.2.3.4:80", 100);
678        let _ = mgr.initiate_punch("p2", "5.6.7.8:80", 200);
679        assert_eq!(mgr.stats().total_attempts, 2);
680    }
681
682    // ---- Status updates ----
683
684    #[test]
685    fn update_attempt_success() {
686        let mut mgr = NatTraversalManager::new(default_config());
687        let _ = mgr.initiate_punch("peer-1", "1.2.3.4:8000", 5000);
688        let result = mgr.update_attempt("peer-1", HolePunchStatus::Success);
689        assert!(result.is_ok());
690        assert_eq!(mgr.stats().successful, 1);
691    }
692
693    #[test]
694    fn update_attempt_unknown_peer() {
695        let mut mgr = NatTraversalManager::new(default_config());
696        let result = mgr.update_attempt("ghost", HolePunchStatus::Success);
697        assert!(result.is_err());
698    }
699
700    #[test]
701    fn update_attempt_failed() {
702        let mut mgr = NatTraversalManager::new(default_config());
703        let _ = mgr.initiate_punch("peer-1", "1.2.3.4:8000", 5000);
704        let _ = mgr.update_attempt("peer-1", HolePunchStatus::Failed);
705        assert_eq!(mgr.stats().failed, 1);
706    }
707
708    #[test]
709    fn update_attempt_timed_out() {
710        let mut mgr = NatTraversalManager::new(default_config());
711        let _ = mgr.initiate_punch("peer-1", "1.2.3.4:8000", 5000);
712        let _ = mgr.update_attempt("peer-1", HolePunchStatus::TimedOut);
713        assert_eq!(mgr.stats().timed_out, 1);
714    }
715
716    #[test]
717    fn update_attempt_max_exceeded_auto_fails() {
718        let config = NatTraversalConfig {
719            max_punch_attempts: 2,
720            ..default_config()
721        };
722        let mut mgr = NatTraversalManager::new(config);
723        let _ = mgr.initiate_punch("peer-1", "1.2.3.4:8000", 5000);
724
725        // 3 InProgress updates — third should auto-fail
726        let _ = mgr.update_attempt("peer-1", HolePunchStatus::InProgress);
727        let _ = mgr.update_attempt("peer-1", HolePunchStatus::InProgress);
728        let _ = mgr.update_attempt("peer-1", HolePunchStatus::InProgress);
729
730        let attempt = mgr.get_attempt("peer-1");
731        assert!(attempt.is_some());
732        assert_eq!(attempt.map(|a| &a.status), Some(&HolePunchStatus::Failed));
733        assert_eq!(mgr.stats().failed, 1);
734    }
735
736    // ---- get_attempt ----
737
738    #[test]
739    fn get_attempt_existing() {
740        let mut mgr = NatTraversalManager::new(default_config());
741        let _ = mgr.initiate_punch("peer-1", "1.2.3.4:80", 100);
742        let a = mgr.get_attempt("peer-1");
743        assert!(a.is_some());
744        assert_eq!(a.map(|x| &x.peer_id).unwrap_or(&String::new()), "peer-1");
745    }
746
747    #[test]
748    fn get_attempt_missing() {
749        let mgr = NatTraversalManager::new(default_config());
750        assert!(mgr.get_attempt("nobody").is_none());
751    }
752
753    // ---- active_punch_count ----
754
755    #[test]
756    fn active_punch_count_excludes_terminal() {
757        let mut mgr = NatTraversalManager::new(default_config());
758        let _ = mgr.initiate_punch("p1", "1.2.3.4:80", 100);
759        let _ = mgr.initiate_punch("p2", "5.6.7.8:80", 200);
760        let _ = mgr.update_attempt("p1", HolePunchStatus::Success);
761        // p1 is terminal, p2 is pending
762        assert_eq!(mgr.active_punch_count(), 1);
763    }
764
765    // ---- cleanup_expired ----
766
767    #[test]
768    fn cleanup_expired_removes_old_terminal() {
769        let mut mgr = NatTraversalManager::new(default_config());
770        let _ = mgr.initiate_punch("p1", "1.2.3.4:80", 100);
771        let _ = mgr.update_attempt("p1", HolePunchStatus::Success);
772
773        // Fake "now" far in the future
774        let removed = mgr.cleanup_expired(u64::MAX);
775        assert_eq!(removed, 1);
776        assert!(mgr.get_attempt("p1").is_none());
777    }
778
779    #[test]
780    fn cleanup_expired_keeps_recent() {
781        let mut mgr = NatTraversalManager::new(default_config());
782        let _ = mgr.initiate_punch("p1", "1.2.3.4:80", 100);
783        // Still pending, not terminal => kept regardless of age
784        let removed = mgr.cleanup_expired(u64::MAX);
785        assert_eq!(removed, 0);
786    }
787
788    // ---- success_rate ----
789
790    #[test]
791    fn success_rate_no_attempts() {
792        let mgr = NatTraversalManager::new(default_config());
793        assert!((mgr.success_rate() - 0.0).abs() < f64::EPSILON);
794    }
795
796    #[test]
797    fn success_rate_half() {
798        let mut mgr = NatTraversalManager::new(default_config());
799        let _ = mgr.initiate_punch("p1", "1.2.3.4:80", 100);
800        let _ = mgr.initiate_punch("p2", "5.6.7.8:80", 200);
801        let _ = mgr.update_attempt("p1", HolePunchStatus::Success);
802        let _ = mgr.update_attempt("p2", HolePunchStatus::Failed);
803        assert!((mgr.success_rate() - 0.5).abs() < f64::EPSILON);
804    }
805
806    // ---- Strategy selection ----
807
808    #[test]
809    fn strategy_open() {
810        let mut mgr = NatTraversalManager::new(default_config());
811        mgr.add_binding(make_binding("1.2.3.4:80", "1.2.3.4:80"));
812        assert_eq!(mgr.best_strategy(), TraversalStrategy::Direct);
813    }
814
815    #[test]
816    fn strategy_full_cone() {
817        let mut mgr = NatTraversalManager::new(default_config());
818        mgr.add_binding(make_binding("192.168.1.1:80", "1.2.3.4:6000"));
819        mgr.add_binding(make_binding("192.168.1.1:81", "1.2.3.4:6000"));
820        assert_eq!(mgr.best_strategy(), TraversalStrategy::HolePunch);
821    }
822
823    #[test]
824    fn strategy_port_restricted() {
825        let mut mgr = NatTraversalManager::new(default_config());
826        mgr.add_binding(make_binding("192.168.1.1:80", "1.2.3.4:6000"));
827        mgr.add_binding(make_binding("192.168.1.1:81", "1.2.3.4:6001"));
828        assert_eq!(mgr.best_strategy(), TraversalStrategy::PortPrediction);
829    }
830
831    #[test]
832    fn strategy_symmetric_with_relay() {
833        let mut mgr = NatTraversalManager::new(default_config());
834        mgr.add_binding(make_binding("192.168.1.1:80", "1.2.3.4:6000"));
835        mgr.add_binding(make_binding("192.168.1.1:81", "5.6.7.8:6001"));
836        assert_eq!(mgr.best_strategy(), TraversalStrategy::Relay);
837    }
838
839    #[test]
840    fn strategy_symmetric_no_relay() {
841        let config = NatTraversalConfig {
842            enable_relay_fallback: false,
843            ..default_config()
844        };
845        let mut mgr = NatTraversalManager::new(config);
846        mgr.add_binding(make_binding("192.168.1.1:80", "1.2.3.4:6000"));
847        mgr.add_binding(make_binding("192.168.1.1:81", "5.6.7.8:6001"));
848        assert_eq!(mgr.best_strategy(), TraversalStrategy::HolePunch);
849    }
850
851    #[test]
852    fn strategy_unknown_defaults_to_relay() {
853        let mgr = NatTraversalManager::new(default_config());
854        assert_eq!(mgr.best_strategy(), TraversalStrategy::Relay);
855    }
856
857    // ---- Stats ----
858
859    #[test]
860    fn stats_default() {
861        let s = NatTraversalStats::default();
862        assert_eq!(s.total_attempts, 0);
863        assert_eq!(s.successful, 0);
864        assert_eq!(s.failed, 0);
865        assert_eq!(s.timed_out, 0);
866        assert!((s.avg_punch_time_ms - 0.0).abs() < f64::EPSILON);
867    }
868
869    #[test]
870    fn stats_reference() {
871        let mgr = NatTraversalManager::new(default_config());
872        let s = mgr.stats();
873        assert_eq!(s.total_attempts, 0);
874    }
875
876    // ---- HolePunchAttempt ----
877
878    #[test]
879    fn hole_punch_attempt_clone() {
880        let a = HolePunchAttempt {
881            peer_id: "p1".into(),
882            local_port: 1234,
883            remote_addr: "1.2.3.4:80".into(),
884            attempts: 0,
885            max_attempts: 5,
886            status: HolePunchStatus::Pending,
887            started_at: 100,
888        };
889        let b = a.clone();
890        assert_eq!(a.peer_id, b.peer_id);
891        assert_eq!(a.status, b.status);
892    }
893
894    // ---- NatType enum ----
895
896    #[test]
897    fn nat_type_equality() {
898        assert_eq!(NatType::Open, NatType::Open);
899        assert_ne!(NatType::Open, NatType::FullCone);
900        assert_ne!(NatType::Symmetric, NatType::Unknown);
901    }
902
903    // ---- port_range helper ----
904
905    #[test]
906    fn port_range_basic() {
907        let r = port_range(100, 5);
908        assert_eq!(r.len(), 11); // 95..=105
909        assert_eq!(r[0], 95);
910        assert_eq!(*r.last().unwrap_or(&0), 105);
911    }
912
913    #[test]
914    fn port_range_saturates_low() {
915        let r = port_range(3, 10);
916        assert_eq!(r[0], 0);
917    }
918
919    #[test]
920    fn port_range_saturates_high() {
921        let r = port_range(65530, 10);
922        assert_eq!(*r.last().unwrap_or(&0), 65535);
923    }
924}