//! Minimal RFC 5389 STUN Binding client for public STUN servers. //! //! Implements just enough of STUN to send a Binding Request and parse //! the XOR-MAPPED-ADDRESS from the Binding Response. No TURN, no ICE //! agent, no long-term credentials — just reflexive address discovery //! over raw UDP. //! //! This complements the relay-based `Reflect` mechanism in //! `reflect.rs` by providing independent reflexive discovery via //! public STUN servers (stun.l.google.com, stun.cloudflare.com, etc.) //! without requiring a connection to our own relay infrastructure. use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::time::Duration; use tokio::net::UdpSocket; // ── Constants ────────────────────────────────────────────────────── /// STUN magic cookie (RFC 5389 §6). const MAGIC_COOKIE: u32 = 0x2112_A442; /// STUN message types. const BINDING_REQUEST: u16 = 0x0001; const BINDING_RESPONSE: u16 = 0x0101; /// STUN attribute types. const ATTR_MAPPED_ADDRESS: u16 = 0x0001; const ATTR_XOR_MAPPED_ADDRESS: u16 = 0x0020; /// STUN header is always 20 bytes. const HEADER_LEN: usize = 20; /// Maximum STUN response we'll accept (RFC says < 576 for most, but /// we're generous). const MAX_RESPONSE: usize = 576; /// Well-known public STUN servers. pub const DEFAULT_STUN_SERVERS: &[&str] = &[ "stun.l.google.com:19302", "stun1.l.google.com:19302", "stun.cloudflare.com:3478", ]; // ── Error type ───────────────────────────────────────────────────── #[derive(Debug, Clone)] pub enum StunError { /// Network I/O error. Io(String), /// Timed out waiting for response. Timeout, /// Response packet too short or malformed. Malformed(String), /// Transaction ID mismatch (response doesn't match our request). TxnMismatch, /// Response was a STUN error response (class 0x01, method 0x01 = 0x0111). ErrorResponse(u16), /// No XOR-MAPPED-ADDRESS or MAPPED-ADDRESS in response. NoMappedAddress, /// DNS resolution failed. DnsError(String), } impl std::fmt::Display for StunError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io(e) => write!(f, "STUN I/O: {e}"), Self::Timeout => write!(f, "STUN timeout"), Self::Malformed(e) => write!(f, "STUN malformed: {e}"), Self::TxnMismatch => write!(f, "STUN transaction ID mismatch"), Self::ErrorResponse(code) => write!(f, "STUN error response: {code}"), Self::NoMappedAddress => write!(f, "no MAPPED-ADDRESS in STUN response"), Self::DnsError(e) => write!(f, "STUN DNS: {e}"), } } } impl std::error::Error for StunError {} // ── Configuration ────────────────────────────────────────────────── /// Configuration for public STUN server probing. #[derive(Debug, Clone)] pub struct StunConfig { /// STUN servers to probe, as `host:port` strings. Resolved via /// tokio DNS at probe time. pub servers: Vec, /// Per-server timeout. pub timeout: Duration, } impl Default for StunConfig { fn default() -> Self { Self { servers: DEFAULT_STUN_SERVERS.iter().map(|s| s.to_string()).collect(), timeout: Duration::from_secs(3), } } } // ── Packet encoding ──────────────────────────────────────────────── /// Generate a 12-byte STUN transaction ID. fn gen_txn_id() -> [u8; 12] { let mut id = [0u8; 12]; rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut id); id } /// Encode a STUN Binding Request (20 bytes, no attributes). /// /// ```text /// 0 1 2 3 /// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 /// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ /// |0 0| STUN Message Type | Message Length | /// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ /// | Magic Cookie | /// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ /// | | /// | Transaction ID (96 bits) | /// | | /// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ /// ``` pub fn encode_binding_request(txn_id: &[u8; 12]) -> [u8; HEADER_LEN] { let mut buf = [0u8; HEADER_LEN]; // Message Type: Binding Request (0x0001) buf[0..2].copy_from_slice(&BINDING_REQUEST.to_be_bytes()); // Message Length: 0 (no attributes) buf[2..4].copy_from_slice(&0u16.to_be_bytes()); // Magic Cookie buf[4..8].copy_from_slice(&MAGIC_COOKIE.to_be_bytes()); // Transaction ID buf[8..20].copy_from_slice(txn_id); buf } /// Parse a STUN Binding Response and extract the mapped address. /// /// Returns the XOR-MAPPED-ADDRESS if present, otherwise falls back /// to MAPPED-ADDRESS. Returns `Err` if the response is malformed /// or doesn't contain either attribute. pub fn parse_binding_response( buf: &[u8], expected_txn_id: &[u8; 12], ) -> Result { if buf.len() < HEADER_LEN { return Err(StunError::Malformed(format!( "response too short: {} bytes", buf.len() ))); } // Parse header. let msg_type = u16::from_be_bytes([buf[0], buf[1]]); let msg_len = u16::from_be_bytes([buf[2], buf[3]]) as usize; let cookie = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]); // Verify magic cookie. if cookie != MAGIC_COOKIE { return Err(StunError::Malformed(format!( "bad magic cookie: {cookie:#010x}" ))); } // Verify it's a Binding Response (not an error response). if msg_type == 0x0111 { // Error response — try to extract error code. return Err(StunError::ErrorResponse(0)); } if msg_type != BINDING_RESPONSE { return Err(StunError::Malformed(format!( "unexpected message type: {msg_type:#06x}" ))); } // Verify transaction ID. if buf[8..20] != *expected_txn_id { return Err(StunError::TxnMismatch); } // Verify message length doesn't exceed buffer. let total_len = HEADER_LEN + msg_len; if buf.len() < total_len { return Err(StunError::Malformed(format!( "message length {msg_len} exceeds buffer ({} bytes after header)", buf.len() - HEADER_LEN ))); } // Walk attributes looking for XOR-MAPPED-ADDRESS (preferred) or // MAPPED-ADDRESS (fallback). XOR-MAPPED-ADDRESS is preferred // because it survives ALG rewriting by broken NATs. let attrs = &buf[HEADER_LEN..total_len]; let mut mapped: Option = None; let mut xor_mapped: Option = None; let mut pos = 0; while pos + 4 <= attrs.len() { let attr_type = u16::from_be_bytes([attrs[pos], attrs[pos + 1]]); let attr_len = u16::from_be_bytes([attrs[pos + 2], attrs[pos + 3]]) as usize; let value_start = pos + 4; let value_end = value_start + attr_len; if value_end > attrs.len() { break; // truncated attribute — stop parsing } let value = &attrs[value_start..value_end]; match attr_type { ATTR_XOR_MAPPED_ADDRESS => { xor_mapped = parse_xor_mapped_address(value, expected_txn_id).ok(); } ATTR_MAPPED_ADDRESS => { mapped = parse_mapped_address(value).ok(); } _ => {} // ignore unknown attributes } // Attributes are padded to 4-byte boundaries. pos = value_end + ((4 - (attr_len % 4)) % 4); } xor_mapped .or(mapped) .ok_or(StunError::NoMappedAddress) } /// Parse a MAPPED-ADDRESS attribute value (RFC 5389 §15.1). /// /// ```text /// 0 1 2 3 /// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 /// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ /// |0 0 0 0 0 0 0 0| Family | Port | /// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ /// | | /// | Address (32 bits or 128 bits) | /// | | /// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ /// ``` fn parse_mapped_address(value: &[u8]) -> Result { if value.len() < 4 { return Err(StunError::Malformed("MAPPED-ADDRESS too short".into())); } let family = value[1]; let port = u16::from_be_bytes([value[2], value[3]]); match family { 0x01 => { // IPv4 if value.len() < 8 { return Err(StunError::Malformed("MAPPED-ADDRESS IPv4 too short".into())); } let ip = Ipv4Addr::new(value[4], value[5], value[6], value[7]); Ok(SocketAddr::new(IpAddr::V4(ip), port)) } 0x02 => { // IPv6 if value.len() < 20 { return Err(StunError::Malformed("MAPPED-ADDRESS IPv6 too short".into())); } let mut octets = [0u8; 16]; octets.copy_from_slice(&value[4..20]); let ip = Ipv6Addr::from(octets); Ok(SocketAddr::new(IpAddr::V6(ip), port)) } _ => Err(StunError::Malformed(format!( "unknown address family: {family:#04x}" ))), } } /// Parse an XOR-MAPPED-ADDRESS attribute value (RFC 5389 §15.2). /// /// Same layout as MAPPED-ADDRESS but port and address are XORed: /// - Port: XOR with top 16 bits of magic cookie /// - IPv4 address: XOR with magic cookie /// - IPv6 address: XOR with magic cookie || transaction ID fn parse_xor_mapped_address( value: &[u8], txn_id: &[u8; 12], ) -> Result { if value.len() < 4 { return Err(StunError::Malformed("XOR-MAPPED-ADDRESS too short".into())); } let family = value[1]; let xport = u16::from_be_bytes([value[2], value[3]]); let port = xport ^ (MAGIC_COOKIE >> 16) as u16; match family { 0x01 => { // IPv4: XOR with magic cookie (big-endian) if value.len() < 8 { return Err(StunError::Malformed( "XOR-MAPPED-ADDRESS IPv4 too short".into(), )); } let cookie_bytes = MAGIC_COOKIE.to_be_bytes(); let ip = Ipv4Addr::new( value[4] ^ cookie_bytes[0], value[5] ^ cookie_bytes[1], value[6] ^ cookie_bytes[2], value[7] ^ cookie_bytes[3], ); Ok(SocketAddr::new(IpAddr::V4(ip), port)) } 0x02 => { // IPv6: XOR with magic cookie (4 bytes) || txn ID (12 bytes) if value.len() < 20 { return Err(StunError::Malformed( "XOR-MAPPED-ADDRESS IPv6 too short".into(), )); } let cookie_bytes = MAGIC_COOKIE.to_be_bytes(); let mut xor_key = [0u8; 16]; xor_key[..4].copy_from_slice(&cookie_bytes); xor_key[4..16].copy_from_slice(txn_id); let mut octets = [0u8; 16]; for i in 0..16 { octets[i] = value[4 + i] ^ xor_key[i]; } let ip = Ipv6Addr::from(octets); Ok(SocketAddr::new(IpAddr::V6(ip), port)) } _ => Err(StunError::Malformed(format!( "unknown address family: {family:#04x}" ))), } } // ── Public async API ─────────────────────────────────────────────── /// Send a STUN Binding Request to `server` over `socket` and return /// the server-reflexive address from the response. /// /// The socket should be a `UdpSocket` bound to `0.0.0.0:0` (or a /// specific port if you want to test the same source port as QUIC). /// The function does NOT connect the socket — it uses `send_to` / /// `recv_from` so the socket can be reused for multiple servers. pub async fn stun_reflect( socket: &UdpSocket, server: SocketAddr, timeout: Duration, ) -> Result { let txn_id = gen_txn_id(); let request = encode_binding_request(&txn_id); socket .send_to(&request, server) .await .map_err(|e| StunError::Io(e.to_string()))?; let mut buf = [0u8; MAX_RESPONSE]; // Retry once: some NATs drop the first UDP packet to a new // destination (the "first-packet problem"). A single retry at // half the timeout covers this without adding excessive delay. let half = timeout / 2; let addr = match tokio::time::timeout(half, socket.recv_from(&mut buf)).await { Ok(Ok((len, from))) => { // Verify response is from the server we queried. if from.ip() != server.ip() { return Err(StunError::Malformed(format!( "response from unexpected source: {from} (expected {server})" ))); } parse_binding_response(&buf[..len], &txn_id)? } Ok(Err(e)) => return Err(StunError::Io(e.to_string())), Err(_) => { // First attempt timed out — retry. socket .send_to(&request, server) .await .map_err(|e| StunError::Io(e.to_string()))?; let (len, _from) = tokio::time::timeout(half, socket.recv_from(&mut buf)) .await .map_err(|_| StunError::Timeout)? .map_err(|e| StunError::Io(e.to_string()))?; parse_binding_response(&buf[..len], &txn_id)? } }; Ok(addr) } /// Resolve a STUN server hostname to a `SocketAddr`. /// /// Uses tokio's DNS resolver. Returns the first IPv4 address found, /// or the first IPv6 if no IPv4 is available. pub async fn resolve_stun_server(host_port: &str) -> Result { use tokio::net::lookup_host; let mut addrs = lookup_host(host_port) .await .map_err(|e| StunError::DnsError(format!("{host_port}: {e}")))?; // Prefer IPv4 for STUN since our QUIC endpoint is currently // IPv4-only (Phase 7 IPv6 is still flaky). let mut first_v6: Option = None; while let Some(addr) = addrs.next() { if addr.is_ipv4() { return Ok(addr); } if first_v6.is_none() { first_v6 = Some(addr); } } first_v6.ok_or_else(|| StunError::DnsError(format!("{host_port}: no addresses resolved"))) } /// Probe multiple public STUN servers in parallel and return the /// reflexive address from the first successful response. /// /// This is the high-level entry point for Phase 1 STUN integration. /// Call it during call setup alongside (or instead of) the relay- /// based `probe_reflect_addr`. pub async fn discover_reflexive(config: &StunConfig) -> Result { if config.servers.is_empty() { return Err(StunError::Io("no STUN servers configured".into())); } let mut set = tokio::task::JoinSet::new(); for server_str in &config.servers { let server_str = server_str.clone(); let timeout = config.timeout; // We can't share &UdpSocket across spawned tasks (not Send // on all platforms), so each task creates its own socket. // For NAT classification purposes this is actually fine — if // the NAT is cone, all sockets see the same IP; if symmetric, // they'll differ (and we'll detect that in classify_nat). set.spawn(async move { let sock = UdpSocket::bind("0.0.0.0:0") .await .map_err(|e| StunError::Io(format!("bind: {e}")))?; let addr = resolve_stun_server(&server_str).await?; stun_reflect(&sock, addr, timeout).await }); } // Return first success. Collect errors for diagnostics. let mut last_err: Option = None; while let Some(join_result) = set.join_next().await { match join_result { Ok(Ok(addr)) => { set.abort_all(); return Ok(addr); } Ok(Err(e)) => { last_err = Some(e); } Err(_join_err) => { last_err = Some(StunError::Io("STUN task panicked".into())); } } } Err(last_err.unwrap_or(StunError::Io("no STUN servers responded".into()))) } /// Probe multiple STUN servers and return per-server results suitable /// for feeding into `classify_nat` alongside relay-based probes. /// /// Unlike `discover_reflexive` (which returns on first success), this /// waits for ALL servers and returns individual results — needed for /// NAT type classification which requires 2+ observations. pub async fn probe_stun_servers( config: &StunConfig, ) -> Vec { use std::time::Instant; let mut set = tokio::task::JoinSet::new(); for server_str in &config.servers { let server_str = server_str.clone(); let timeout = config.timeout; set.spawn(async move { let start = Instant::now(); let sock = match UdpSocket::bind("0.0.0.0:0").await { Ok(s) => s, Err(e) => { return crate::reflect::NatProbeResult { relay_name: format!("stun:{server_str}"), relay_addr: server_str, observed_addr: None, latency_ms: None, error: Some(format!("bind: {e}")), }; } }; let resolved = match resolve_stun_server(&server_str).await { Ok(a) => a, Err(e) => { return crate::reflect::NatProbeResult { relay_name: format!("stun:{server_str}"), relay_addr: server_str, observed_addr: None, latency_ms: None, error: Some(e.to_string()), }; } }; match stun_reflect(&sock, resolved, timeout).await { Ok(addr) => crate::reflect::NatProbeResult { relay_name: format!("stun:{server_str}"), relay_addr: resolved.to_string(), observed_addr: Some(addr.to_string()), latency_ms: Some(start.elapsed().as_millis() as u32), error: None, }, Err(e) => crate::reflect::NatProbeResult { relay_name: format!("stun:{server_str}"), relay_addr: resolved.to_string(), observed_addr: None, latency_ms: None, error: Some(e.to_string()), }, } }); } let mut results = Vec::with_capacity(config.servers.len()); while let Some(join_result) = set.join_next().await { match join_result { Ok(result) => results.push(result), Err(_) => results.push(crate::reflect::NatProbeResult { relay_name: "stun:".into(), relay_addr: "unknown".into(), observed_addr: None, latency_ms: None, error: Some("STUN probe task panicked".into()), }), } } results } // ── Port allocation pattern detection ────────────────────────────── /// NAT port allocation pattern, detected by probing multiple STUN /// servers from a single socket and analyzing the observed external /// port sequence. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub enum PortAllocation { /// Same external port for all destinations — cone-like NAT. /// Standard hole-punching works; no hard NAT techniques needed. PortPreserving, /// Ports increment by a consistent delta per new flow. /// Port prediction is viable: next_port = last_port + delta. Sequential { delta: i16 }, /// No discernible pattern — truly random allocation. /// Only birthday attack or relay can traverse this. Random, /// Not enough data to classify (< 3 successful probes). Unknown, } impl std::fmt::Display for PortAllocation { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::PortPreserving => write!(f, "port-preserving"), Self::Sequential { delta } => write!(f, "sequential(delta={delta})"), Self::Random => write!(f, "random"), Self::Unknown => write!(f, "unknown"), } } } /// Result of port allocation analysis. #[derive(Debug, Clone, serde::Serialize)] pub struct PortAllocationResult { /// Detected allocation pattern. pub allocation: PortAllocation, /// Observed external ports (one per successful STUN probe), /// in probe order. pub observed_ports: Vec, /// External IP (consensus from probes, if available). pub external_ip: Option, } /// Detect the NAT's port allocation pattern by sending STUN probes /// to multiple servers from a **single socket**. /// /// Unlike `probe_stun_servers` (which creates one socket per server /// for NAT type classification), this uses one socket so we see how /// the NAT maps the SAME source port to different destinations. /// /// - Same external port for all → `PortPreserving` (cone-like) /// - Consistent delta → `Sequential { delta }` /// - No pattern → `Random` /// /// Requires at least 3 servers for reliable classification. pub async fn detect_port_allocation( config: &StunConfig, ) -> PortAllocationResult { if config.servers.len() < 2 { return PortAllocationResult { allocation: PortAllocation::Unknown, observed_ports: vec![], external_ip: None, }; } // Single socket — all probes share the same source port. let socket = match UdpSocket::bind("0.0.0.0:0").await { Ok(s) => s, Err(_) => { return PortAllocationResult { allocation: PortAllocation::Unknown, observed_ports: vec![], external_ip: None, }; } }; // Probe servers SEQUENTIALLY (not parallel) so the NAT sees // distinct flows in order. Parallel probes could arrive out-of- // order and confuse sequential delta detection. let mut observed: Vec = Vec::new(); for server_str in &config.servers { let resolved = match resolve_stun_server(server_str).await { Ok(a) => a, Err(_) => continue, }; match stun_reflect(&socket, resolved, config.timeout).await { Ok(addr) => observed.push(addr), Err(_) => continue, } } let ports: Vec = observed.iter().map(|a| a.port()).collect(); let external_ip = observed.first().map(|a| a.ip()); let allocation = classify_port_allocation(&ports); tracing::info!( ?allocation, ?ports, external_ip = ?external_ip, "stun: port allocation detected" ); PortAllocationResult { allocation, observed_ports: ports, external_ip, } } /// Pure-function classifier — split out for unit testing. pub fn classify_port_allocation(ports: &[u16]) -> PortAllocation { if ports.len() < 2 { return PortAllocation::Unknown; } // All same port? if ports.iter().all(|&p| p == ports[0]) { return PortAllocation::PortPreserving; } if ports.len() < 3 { // With only 2 different ports we can't distinguish // sequential from random reliably. return PortAllocation::Unknown; } // Compute deltas between consecutive ports. let deltas: Vec = ports .windows(2) .map(|w| w[1] as i32 - w[0] as i32) .map(|d| { // Handle wraparound: if delta is huge negative (e.g., // 65535 -> 2 = -65533), treat as +3. And vice versa. if d > 32768 { (d - 65536) as i16 } else if d < -32768 { (d + 65536) as i16 } else { d as i16 } }) .collect(); // Check if all deltas are the same (sequential pattern). let first_delta = deltas[0]; if first_delta == 0 { // All same port was already handled above, this means // mixed same/different — not sequential. return PortAllocation::Random; } // Allow small jitter: if all deltas are within ±1 of each other, // consider it sequential with the median delta. let all_close = deltas.iter().all(|&d| (d - first_delta).unsigned_abs() <= 1); if all_close { // Use the most common delta (mode). let median_delta = first_delta; return PortAllocation::Sequential { delta: median_delta }; } // Check for consistent delta with occasional skip (some NATs // skip a port when another flow grabs it concurrently). // If most deltas (>= 60%) agree on the same value, call it // sequential. let mut delta_counts = std::collections::HashMap::new(); for &d in &deltas { *delta_counts.entry(d).or_insert(0u32) += 1; } if let Some((&most_common, &count)) = delta_counts.iter().max_by_key(|(_, v)| *v) { let threshold = (deltas.len() as f64 * 0.6).ceil() as u32; if count >= threshold && most_common != 0 { return PortAllocation::Sequential { delta: most_common }; } } PortAllocation::Random } /// Predict the next N external ports for a sequential NAT. /// /// Given the last observed port and the delta, returns a range of /// predicted ports centered around the most likely next value. /// The `offset` parameter accounts for additional flows that may /// open between the probe and the actual connection attempt. pub fn predict_ports( last_port: u16, delta: i16, offset: u16, spread: u16, ) -> Vec { let base = last_port as i32 + (delta as i32 * (offset as i32 + 1)); let mut ports = Vec::with_capacity((spread * 2 + 1) as usize); for i in -(spread as i32)..=(spread as i32) { let p = base + (i * delta as i32); // Wrap to valid port range (1..=65535) let p = ((p % 65536) + 65536) % 65536; if p > 0 && p <= 65535 { ports.push(p as u16); } } ports.sort(); ports.dedup(); ports } // ── Tests ────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn encode_binding_request_is_20_bytes() { let txn_id = [1u8; 12]; let pkt = encode_binding_request(&txn_id); assert_eq!(pkt.len(), 20); // First two bytes: Binding Request type assert_eq!(pkt[0], 0x00); assert_eq!(pkt[1], 0x01); // Bytes 2-3: message length = 0 assert_eq!(pkt[2], 0x00); assert_eq!(pkt[3], 0x00); // Bytes 4-7: magic cookie assert_eq!(&pkt[4..8], &MAGIC_COOKIE.to_be_bytes()); // Bytes 8-19: transaction ID assert_eq!(&pkt[8..20], &txn_id); } #[test] fn parse_xor_mapped_address_ipv4() { let txn_id = [0u8; 12]; // Build a minimal Binding Response with XOR-MAPPED-ADDRESS // for 203.0.113.5:12345 let ip = Ipv4Addr::new(203, 0, 113, 5); let port: u16 = 12345; // XOR the port and IP let xport = port ^ (MAGIC_COOKIE >> 16) as u16; let cookie_bytes = MAGIC_COOKIE.to_be_bytes(); let ip_octets = ip.octets(); let xip = [ ip_octets[0] ^ cookie_bytes[0], ip_octets[1] ^ cookie_bytes[1], ip_octets[2] ^ cookie_bytes[2], ip_octets[3] ^ cookie_bytes[3], ]; // Attribute: XOR-MAPPED-ADDRESS (type 0x0020, length 8) let mut attr = Vec::new(); attr.extend_from_slice(&ATTR_XOR_MAPPED_ADDRESS.to_be_bytes()); attr.extend_from_slice(&8u16.to_be_bytes()); // length attr.push(0x00); // reserved attr.push(0x01); // family: IPv4 attr.extend_from_slice(&xport.to_be_bytes()); attr.extend_from_slice(&xip); // Build full response let mut pkt = Vec::new(); pkt.extend_from_slice(&BINDING_RESPONSE.to_be_bytes()); pkt.extend_from_slice(&(attr.len() as u16).to_be_bytes()); pkt.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); pkt.extend_from_slice(&txn_id); pkt.extend_from_slice(&attr); let result = parse_binding_response(&pkt, &txn_id).unwrap(); assert_eq!(result, SocketAddr::new(IpAddr::V4(ip), port)); } #[test] fn parse_xor_mapped_address_ipv6() { let txn_id = [0xAB; 12]; let ip = Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1); let port: u16 = 54321; let xport = port ^ (MAGIC_COOKIE >> 16) as u16; let cookie_bytes = MAGIC_COOKIE.to_be_bytes(); let ip_octets = ip.octets(); let mut xor_key = [0u8; 16]; xor_key[..4].copy_from_slice(&cookie_bytes); xor_key[4..16].copy_from_slice(&txn_id); let mut xip = [0u8; 16]; for i in 0..16 { xip[i] = ip_octets[i] ^ xor_key[i]; } let mut attr = Vec::new(); attr.extend_from_slice(&ATTR_XOR_MAPPED_ADDRESS.to_be_bytes()); attr.extend_from_slice(&20u16.to_be_bytes()); // length attr.push(0x00); // reserved attr.push(0x02); // family: IPv6 attr.extend_from_slice(&xport.to_be_bytes()); attr.extend_from_slice(&xip); let mut pkt = Vec::new(); pkt.extend_from_slice(&BINDING_RESPONSE.to_be_bytes()); pkt.extend_from_slice(&(attr.len() as u16).to_be_bytes()); pkt.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); pkt.extend_from_slice(&txn_id); pkt.extend_from_slice(&attr); let result = parse_binding_response(&pkt, &txn_id).unwrap(); assert_eq!(result, SocketAddr::new(IpAddr::V6(ip), port)); } #[test] fn parse_mapped_address_fallback() { let txn_id = [0u8; 12]; let ip = Ipv4Addr::new(198, 51, 100, 42); let port: u16 = 8080; // Attribute: MAPPED-ADDRESS (type 0x0001, length 8) let mut attr = Vec::new(); attr.extend_from_slice(&ATTR_MAPPED_ADDRESS.to_be_bytes()); attr.extend_from_slice(&8u16.to_be_bytes()); attr.push(0x00); // reserved attr.push(0x01); // family: IPv4 attr.extend_from_slice(&port.to_be_bytes()); attr.extend_from_slice(&ip.octets()); let mut pkt = Vec::new(); pkt.extend_from_slice(&BINDING_RESPONSE.to_be_bytes()); pkt.extend_from_slice(&(attr.len() as u16).to_be_bytes()); pkt.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); pkt.extend_from_slice(&txn_id); pkt.extend_from_slice(&attr); let result = parse_binding_response(&pkt, &txn_id).unwrap(); assert_eq!(result, SocketAddr::new(IpAddr::V4(ip), port)); } #[test] fn parse_rejects_wrong_txn_id() { let txn_id = [1u8; 12]; let wrong_txn = [2u8; 12]; let mut pkt = Vec::new(); pkt.extend_from_slice(&BINDING_RESPONSE.to_be_bytes()); pkt.extend_from_slice(&0u16.to_be_bytes()); pkt.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); pkt.extend_from_slice(&wrong_txn); let err = parse_binding_response(&pkt, &txn_id).unwrap_err(); assert!(matches!(err, StunError::TxnMismatch)); } #[test] fn parse_rejects_too_short() { let txn_id = [0u8; 12]; let err = parse_binding_response(&[0u8; 10], &txn_id).unwrap_err(); assert!(matches!(err, StunError::Malformed(_))); } #[test] fn parse_rejects_bad_cookie() { let txn_id = [0u8; 12]; let mut pkt = [0u8; 20]; pkt[0..2].copy_from_slice(&BINDING_RESPONSE.to_be_bytes()); pkt[4..8].copy_from_slice(&0xDEADBEEFu32.to_be_bytes()); pkt[8..20].copy_from_slice(&txn_id); let err = parse_binding_response(&pkt, &txn_id).unwrap_err(); assert!(matches!(err, StunError::Malformed(_))); } #[test] fn parse_no_mapped_address() { let txn_id = [0u8; 12]; // Valid response with zero-length body (no attributes) let mut pkt = Vec::new(); pkt.extend_from_slice(&BINDING_RESPONSE.to_be_bytes()); pkt.extend_from_slice(&0u16.to_be_bytes()); pkt.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); pkt.extend_from_slice(&txn_id); let err = parse_binding_response(&pkt, &txn_id).unwrap_err(); assert!(matches!(err, StunError::NoMappedAddress)); } #[test] fn xor_mapped_preferred_over_mapped() { let txn_id = [0u8; 12]; // Build two attributes: MAPPED-ADDRESS with one IP, then // XOR-MAPPED-ADDRESS with a different IP. let mapped_ip = Ipv4Addr::new(10, 0, 0, 1); let xor_ip = Ipv4Addr::new(203, 0, 113, 5); let port: u16 = 9999; let mut attrs = Vec::new(); // MAPPED-ADDRESS attrs.extend_from_slice(&ATTR_MAPPED_ADDRESS.to_be_bytes()); attrs.extend_from_slice(&8u16.to_be_bytes()); attrs.push(0x00); attrs.push(0x01); attrs.extend_from_slice(&port.to_be_bytes()); attrs.extend_from_slice(&mapped_ip.octets()); // XOR-MAPPED-ADDRESS let xport = port ^ (MAGIC_COOKIE >> 16) as u16; let cookie_bytes = MAGIC_COOKIE.to_be_bytes(); let xip_octets = xor_ip.octets(); let xip = [ xip_octets[0] ^ cookie_bytes[0], xip_octets[1] ^ cookie_bytes[1], xip_octets[2] ^ cookie_bytes[2], xip_octets[3] ^ cookie_bytes[3], ]; attrs.extend_from_slice(&ATTR_XOR_MAPPED_ADDRESS.to_be_bytes()); attrs.extend_from_slice(&8u16.to_be_bytes()); attrs.push(0x00); attrs.push(0x01); attrs.extend_from_slice(&xport.to_be_bytes()); attrs.extend_from_slice(&xip); let mut pkt = Vec::new(); pkt.extend_from_slice(&BINDING_RESPONSE.to_be_bytes()); pkt.extend_from_slice(&(attrs.len() as u16).to_be_bytes()); pkt.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); pkt.extend_from_slice(&txn_id); pkt.extend_from_slice(&attrs); let result = parse_binding_response(&pkt, &txn_id).unwrap(); // XOR-MAPPED-ADDRESS should win assert_eq!(result, SocketAddr::new(IpAddr::V4(xor_ip), port)); } // ── Additional edge-case tests ──────────────────────────────── #[test] fn encode_txn_id_is_random() { let a = gen_txn_id(); let b = gen_txn_id(); // Extremely unlikely to collide (96-bit random). assert_ne!(a, b, "two txn IDs should differ"); } #[test] fn parse_error_response_0x0111() { let txn_id = [0u8; 12]; let mut pkt = Vec::new(); // Error response type = 0x0111 pkt.extend_from_slice(&0x0111u16.to_be_bytes()); pkt.extend_from_slice(&0u16.to_be_bytes()); pkt.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); pkt.extend_from_slice(&txn_id); let err = parse_binding_response(&pkt, &txn_id).unwrap_err(); assert!(matches!(err, StunError::ErrorResponse(_))); } #[test] fn parse_unknown_message_type() { let txn_id = [0u8; 12]; let mut pkt = Vec::new(); // Some unknown type 0x0042 pkt.extend_from_slice(&0x0042u16.to_be_bytes()); pkt.extend_from_slice(&0u16.to_be_bytes()); pkt.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); pkt.extend_from_slice(&txn_id); let err = parse_binding_response(&pkt, &txn_id).unwrap_err(); assert!(matches!(err, StunError::Malformed(_))); } #[test] fn parse_truncated_attribute_is_handled() { let txn_id = [0u8; 12]; // Attribute header says length=100 but buffer ends after 4 bytes let mut attr = Vec::new(); attr.extend_from_slice(&ATTR_XOR_MAPPED_ADDRESS.to_be_bytes()); attr.extend_from_slice(&100u16.to_be_bytes()); // claims 100 bytes // No actual value bytes — truncated let mut pkt = Vec::new(); pkt.extend_from_slice(&BINDING_RESPONSE.to_be_bytes()); pkt.extend_from_slice(&(attr.len() as u16).to_be_bytes()); pkt.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); pkt.extend_from_slice(&txn_id); pkt.extend_from_slice(&attr); // Should NOT panic — truncated attribute is skipped, then NoMappedAddress let err = parse_binding_response(&pkt, &txn_id).unwrap_err(); assert!(matches!(err, StunError::NoMappedAddress)); } #[test] fn parse_unknown_attributes_skipped() { let txn_id = [0u8; 12]; let ip = Ipv4Addr::new(192, 0, 2, 99); let port: u16 = 5000; let mut attrs = Vec::new(); // Unknown attribute type 0x8000 (comprehension-optional), 4 bytes attrs.extend_from_slice(&0x8000u16.to_be_bytes()); attrs.extend_from_slice(&4u16.to_be_bytes()); attrs.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); // The real XOR-MAPPED-ADDRESS after the unknown one let xport = port ^ (MAGIC_COOKIE >> 16) as u16; let cookie_bytes = MAGIC_COOKIE.to_be_bytes(); let xip = [ ip.octets()[0] ^ cookie_bytes[0], ip.octets()[1] ^ cookie_bytes[1], ip.octets()[2] ^ cookie_bytes[2], ip.octets()[3] ^ cookie_bytes[3], ]; attrs.extend_from_slice(&ATTR_XOR_MAPPED_ADDRESS.to_be_bytes()); attrs.extend_from_slice(&8u16.to_be_bytes()); attrs.push(0x00); attrs.push(0x01); attrs.extend_from_slice(&xport.to_be_bytes()); attrs.extend_from_slice(&xip); let mut pkt = Vec::new(); pkt.extend_from_slice(&BINDING_RESPONSE.to_be_bytes()); pkt.extend_from_slice(&(attrs.len() as u16).to_be_bytes()); pkt.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); pkt.extend_from_slice(&txn_id); pkt.extend_from_slice(&attrs); let result = parse_binding_response(&pkt, &txn_id).unwrap(); assert_eq!(result, SocketAddr::new(IpAddr::V4(ip), port)); } #[test] fn parse_message_length_exceeds_buffer() { let txn_id = [0u8; 12]; let mut pkt = Vec::new(); pkt.extend_from_slice(&BINDING_RESPONSE.to_be_bytes()); // Claims 500 bytes of attributes but buffer is only header pkt.extend_from_slice(&500u16.to_be_bytes()); pkt.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); pkt.extend_from_slice(&txn_id); let err = parse_binding_response(&pkt, &txn_id).unwrap_err(); assert!(matches!(err, StunError::Malformed(_))); } #[test] fn parse_xor_mapped_ipv4_high_port() { // Port 65535 — tests boundary of u16 XOR let txn_id = [0xFF; 12]; let ip = Ipv4Addr::new(255, 255, 255, 255); let port: u16 = 65535; let xport = port ^ (MAGIC_COOKIE >> 16) as u16; let cookie_bytes = MAGIC_COOKIE.to_be_bytes(); let xip = [ ip.octets()[0] ^ cookie_bytes[0], ip.octets()[1] ^ cookie_bytes[1], ip.octets()[2] ^ cookie_bytes[2], ip.octets()[3] ^ cookie_bytes[3], ]; let mut attr = Vec::new(); attr.extend_from_slice(&ATTR_XOR_MAPPED_ADDRESS.to_be_bytes()); attr.extend_from_slice(&8u16.to_be_bytes()); attr.push(0x00); attr.push(0x01); attr.extend_from_slice(&xport.to_be_bytes()); attr.extend_from_slice(&xip); let mut pkt = Vec::new(); pkt.extend_from_slice(&BINDING_RESPONSE.to_be_bytes()); pkt.extend_from_slice(&(attr.len() as u16).to_be_bytes()); pkt.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); pkt.extend_from_slice(&txn_id); pkt.extend_from_slice(&attr); let result = parse_binding_response(&pkt, &txn_id).unwrap(); assert_eq!(result.port(), 65535); assert_eq!(result.ip(), IpAddr::V4(ip)); } #[test] fn parse_mapped_address_ipv6() { let txn_id = [0u8; 12]; let ip = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x42); let port: u16 = 3478; let mut attr = Vec::new(); attr.extend_from_slice(&ATTR_MAPPED_ADDRESS.to_be_bytes()); attr.extend_from_slice(&20u16.to_be_bytes()); attr.push(0x00); attr.push(0x02); // family: IPv6 attr.extend_from_slice(&port.to_be_bytes()); attr.extend_from_slice(&ip.octets()); let mut pkt = Vec::new(); pkt.extend_from_slice(&BINDING_RESPONSE.to_be_bytes()); pkt.extend_from_slice(&(attr.len() as u16).to_be_bytes()); pkt.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); pkt.extend_from_slice(&txn_id); pkt.extend_from_slice(&attr); let result = parse_binding_response(&pkt, &txn_id).unwrap(); assert_eq!(result, SocketAddr::new(IpAddr::V6(ip), port)); } #[test] fn parse_mapped_address_unknown_family() { let txn_id = [0u8; 12]; let mut attr = Vec::new(); attr.extend_from_slice(&ATTR_MAPPED_ADDRESS.to_be_bytes()); attr.extend_from_slice(&8u16.to_be_bytes()); attr.push(0x00); attr.push(0x03); // unknown family attr.extend_from_slice(&1234u16.to_be_bytes()); attr.extend_from_slice(&[1, 2, 3, 4]); let mut pkt = Vec::new(); pkt.extend_from_slice(&BINDING_RESPONSE.to_be_bytes()); pkt.extend_from_slice(&(attr.len() as u16).to_be_bytes()); pkt.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); pkt.extend_from_slice(&txn_id); pkt.extend_from_slice(&attr); // Unknown family in the only attribute → NoMappedAddress let err = parse_binding_response(&pkt, &txn_id).unwrap_err(); assert!(matches!(err, StunError::NoMappedAddress)); } #[test] fn parse_attribute_with_padding() { // Attribute with length=5 gets padded to 8 bytes boundary. // Then a real XOR-MAPPED-ADDRESS follows. let txn_id = [0u8; 12]; let ip = Ipv4Addr::new(10, 1, 2, 3); let port: u16 = 7777; let mut attrs = Vec::new(); // SOFTWARE attribute (type 0x8022) with 5 bytes of data attrs.extend_from_slice(&0x8022u16.to_be_bytes()); attrs.extend_from_slice(&5u16.to_be_bytes()); attrs.extend_from_slice(b"hello"); // 3 bytes padding to reach next 4-byte boundary attrs.extend_from_slice(&[0, 0, 0]); // XOR-MAPPED-ADDRESS let xport = port ^ (MAGIC_COOKIE >> 16) as u16; let cookie_bytes = MAGIC_COOKIE.to_be_bytes(); let xip = [ ip.octets()[0] ^ cookie_bytes[0], ip.octets()[1] ^ cookie_bytes[1], ip.octets()[2] ^ cookie_bytes[2], ip.octets()[3] ^ cookie_bytes[3], ]; attrs.extend_from_slice(&ATTR_XOR_MAPPED_ADDRESS.to_be_bytes()); attrs.extend_from_slice(&8u16.to_be_bytes()); attrs.push(0x00); attrs.push(0x01); attrs.extend_from_slice(&xport.to_be_bytes()); attrs.extend_from_slice(&xip); let mut pkt = Vec::new(); pkt.extend_from_slice(&BINDING_RESPONSE.to_be_bytes()); pkt.extend_from_slice(&(attrs.len() as u16).to_be_bytes()); pkt.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); pkt.extend_from_slice(&txn_id); pkt.extend_from_slice(&attrs); let result = parse_binding_response(&pkt, &txn_id).unwrap(); assert_eq!(result, SocketAddr::new(IpAddr::V4(ip), port)); } #[test] fn stun_error_display() { assert!(StunError::Timeout.to_string().contains("timeout")); assert!(StunError::TxnMismatch.to_string().contains("mismatch")); assert!(StunError::NoMappedAddress.to_string().contains("MAPPED")); assert!(StunError::Io("test".into()).to_string().contains("test")); assert!(StunError::DnsError("bad".into()).to_string().contains("bad")); assert!(StunError::ErrorResponse(420).to_string().contains("420")); assert!(StunError::Malformed("x".into()).to_string().contains("x")); } #[test] fn default_stun_config_has_servers() { let cfg = StunConfig::default(); assert!(cfg.servers.len() >= 3); assert!(cfg.timeout.as_secs() > 0); } #[tokio::test] async fn discover_reflexive_empty_servers_errors() { let cfg = StunConfig { servers: vec![], timeout: Duration::from_secs(1), }; let err = discover_reflexive(&cfg).await.unwrap_err(); assert!(matches!(err, StunError::Io(_))); } // ── Port allocation classification tests ──────────────────── #[test] fn classify_port_preserving() { let ports = vec![4433, 4433, 4433, 4433, 4433]; assert_eq!(classify_port_allocation(&ports), PortAllocation::PortPreserving); } #[test] fn classify_sequential_delta_1() { let ports = vec![40001, 40002, 40003, 40004, 40005]; assert_eq!( classify_port_allocation(&ports), PortAllocation::Sequential { delta: 1 } ); } #[test] fn classify_sequential_delta_2() { let ports = vec![50000, 50002, 50004, 50006]; assert_eq!( classify_port_allocation(&ports), PortAllocation::Sequential { delta: 2 } ); } #[test] fn classify_sequential_negative_delta() { // Some NATs decrement let ports = vec![50000, 49999, 49998, 49997]; assert_eq!( classify_port_allocation(&ports), PortAllocation::Sequential { delta: -1 } ); } #[test] fn classify_random() { let ports = vec![40001, 52847, 19432, 61203, 8847]; assert_eq!(classify_port_allocation(&ports), PortAllocation::Random); } #[test] fn classify_too_few_ports() { assert_eq!(classify_port_allocation(&[]), PortAllocation::Unknown); assert_eq!(classify_port_allocation(&[4433]), PortAllocation::Unknown); } #[test] fn classify_two_same_is_preserving() { let ports = vec![4433, 4433]; assert_eq!(classify_port_allocation(&ports), PortAllocation::PortPreserving); } #[test] fn classify_two_different_is_unknown() { // Can't distinguish sequential from random with only 2 points let ports = vec![4433, 4434]; assert_eq!(classify_port_allocation(&ports), PortAllocation::Unknown); } #[test] fn classify_sequential_with_jitter() { // Delta is mostly 1 but one jump of 2 (concurrent flow grabbed a port) let ports = vec![40001, 40002, 40004, 40005, 40006]; // Deltas: [1, 2, 1, 1] — 3 out of 4 are delta=1, above 60% threshold assert_eq!( classify_port_allocation(&ports), PortAllocation::Sequential { delta: 1 } ); } #[test] fn classify_sequential_wraparound() { // Port wraps from 65534 -> 65535 -> 1 -> 2 let ports = vec![65534, 65535, 1, 2]; // Deltas: [1, -65534(→+2), 1] — wraparound handling let alloc = classify_port_allocation(&ports); // Should detect as sequential with delta ~1 assert!( matches!(alloc, PortAllocation::Sequential { delta: 1 }), "wraparound should be sequential, got: {alloc:?}" ); } #[test] fn predict_ports_sequential() { // Last port 40005, delta 1, offset 0, spread 2 let predicted = predict_ports(40005, 1, 0, 2); assert!(predicted.contains(&40006)); // most likely next assert!(predicted.contains(&40004)); // spread -2 assert!(predicted.contains(&40008)); // spread +2 } #[test] fn predict_ports_delta_2() { let predicted = predict_ports(50000, 2, 0, 1); assert!(predicted.contains(&50002)); // next assert!(predicted.contains(&50000)); // spread -1*delta assert!(predicted.contains(&50004)); // spread +1*delta } #[test] fn predict_ports_with_offset() { // offset=2 means 2 extra flows will open before our dial, // so prediction jumps further: 40005 + 1*(2+1) = 40008 let predicted = predict_ports(40005, 1, 2, 1); assert!(predicted.contains(&40008)); } #[test] fn predict_ports_wraparound() { let predicted = predict_ports(65534, 1, 0, 2); // Should handle the u16 wraparound gracefully assert!(predicted.contains(&65535)); assert!(!predicted.is_empty()); } #[test] fn port_allocation_display() { assert_eq!(PortAllocation::PortPreserving.to_string(), "port-preserving"); assert_eq!(PortAllocation::Sequential { delta: 1 }.to_string(), "sequential(delta=1)"); assert_eq!(PortAllocation::Random.to_string(), "random"); assert_eq!(PortAllocation::Unknown.to_string(), "unknown"); } #[test] fn port_allocation_serde() { let alloc = PortAllocation::Sequential { delta: 3 }; let json = serde_json::to_string(&alloc).unwrap(); assert!(json.contains("Sequential")); assert!(json.contains("3")); } #[test] fn port_allocation_result_serde() { let result = PortAllocationResult { allocation: PortAllocation::Sequential { delta: 1 }, observed_ports: vec![40001, 40002, 40003], external_ip: Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5))), }; let json = serde_json::to_string(&result).unwrap(); assert!(json.contains("Sequential")); assert!(json.contains("40001")); assert!(json.contains("203.0.113.5")); } /// Integration test: detect port allocation on real network. #[tokio::test] #[ignore] async fn integration_detect_port_allocation() { let config = StunConfig::default(); let result = detect_port_allocation(&config).await; println!("Port allocation: {:?}", result.allocation); println!("Observed ports: {:?}", result.observed_ports); println!("External IP: {:?}", result.external_ip); assert!(!result.observed_ports.is_empty()); } /// Integration test: actually query stun.l.google.com. /// Ignored by default since it requires network access. #[tokio::test] #[ignore] async fn integration_stun_google() { let config = StunConfig { servers: vec!["stun.l.google.com:19302".into()], timeout: Duration::from_secs(5), }; let addr = discover_reflexive(&config).await.unwrap(); // Should be a public IPv4 address. assert!(addr.ip().is_ipv4() || addr.ip().is_ipv6()); assert!(addr.port() > 0); println!("STUN reflexive address: {addr}"); } /// Integration test: probe multiple servers and get NAT probes. #[tokio::test] #[ignore] async fn integration_probe_stun_servers() { let config = StunConfig::default(); let probes = probe_stun_servers(&config).await; assert!(!probes.is_empty()); let successes: Vec<_> = probes.iter().filter(|p| p.observed_addr.is_some()).collect(); assert!( !successes.is_empty(), "at least one STUN server should respond" ); for p in &probes { println!( "{}: addr={:?} latency={:?}ms err={:?}", p.relay_name, p.observed_addr, p.latency_ms, p.error ); } } }