Skip to main content

stryke/
nat_punch.rs

1//! STUN client + UDP hole-punching state machine for stryke's `stun`
2//! and `punch` builtins.
3//!
4//! No third-party crate dependency — the STUN binary protocol is small
5//! enough (RFC 8489) to roll our own for the Binding Request / Response
6//! path that covers ~99% of real-world use. We support:
7//!
8//!   * Binding Request (the only message type we send)
9//!   * XOR-MAPPED-ADDRESS attribute parsing (the modern form)
10//!   * MAPPED-ADDRESS fallback (some old servers)
11//!   * IPv4 only (IPv6 is a 100-line additive change; left for v2)
12//!
13//! Not implemented (out of scope for v1, would require TURN server access
14//! and full ICE):
15//!   * Symmetric-NAT detection / fallback
16//!   * TURN relay allocation
17//!   * Full ICE candidate gathering and priority pairing
18//!
19//! Hole-punching state machine:
20//!
21//!   while now < deadline:
22//!     send single keepalive datagram to peer_ip:peer_port via our socket
23//!     attempt non-blocking recv with short timeout
24//!     if recv succeeded:
25//!         established — return success with the first received payload
26//!     sleep interval_ms
27//!
28//! Both peers run this simultaneously (via an out-of-band signaling
29//! channel — email, paste, etc., not provided by us). The early bombards
30//! are dropped by both NATs since neither has seen inbound mappings yet;
31//! once both NATs have observed an outbound packet to the peer's ip:port
32//! they each install a forwarding rule for the reverse direction, and
33//! subsequent packets get through.
34
35use std::net::SocketAddr;
36use std::time::{Duration, Instant};
37
38use crate::udp_sockets;
39
40/// Build a STUN Binding Request packet. 20-byte header, no attributes.
41///
42/// Wire format (RFC 8489):
43///   * 0..2:   message type (0x0001 = Binding Request)
44///   * 2..4:   message length in bytes (we send 0 — no attrs)
45///   * 4..8:   magic cookie (0x2112A442) — fixed, identifies STUN
46///   * 8..20:  transaction ID (12 random bytes) — server echoes back so
47///             clients can match responses to requests; also used in
48///             XOR-MAPPED-ADDRESS computation
49pub const STUN_MAGIC_COOKIE: u32 = 0x2112_A442;
50/// `build_binding_request` — see implementation.
51pub fn build_binding_request(tx_id: &[u8; 12]) -> [u8; 20] {
52    let mut pkt = [0u8; 20];
53    pkt[0..2].copy_from_slice(&0x0001u16.to_be_bytes()); // Binding Request
54    pkt[2..4].copy_from_slice(&0u16.to_be_bytes()); // length = 0
55    pkt[4..8].copy_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
56    pkt[8..20].copy_from_slice(tx_id);
57    pkt
58}
59
60/// Parse a STUN Binding Response, returning the public (`IpAddr`, port)
61/// the server saw us coming from. Handles both IPv4 (family=0x01,
62/// 4-byte address) and IPv6 (family=0x02, 16-byte address). The IPv6
63/// XOR computation per RFC 8489 §14.2 is: byte 0..3 of the XOR-addr is
64/// XOR'd with the magic cookie, bytes 4..15 are XOR'd with the
65/// transaction ID — so we need the 12-byte tx_id for IPv6 unscrambling.
66///
67/// Returns `None` on:
68///   * not a Binding Response (msg type ≠ 0x0101)
69///   * truncated packet
70///   * no XOR-MAPPED-ADDRESS or MAPPED-ADDRESS attribute
71///   * unknown family
72pub fn parse_binding_response(pkt: &[u8]) -> Option<(std::net::IpAddr, u16)> {
73    if pkt.len() < 20 {
74        return None;
75    }
76    let msg_type = u16::from_be_bytes([pkt[0], pkt[1]]);
77    if msg_type != 0x0101 {
78        return None;
79    }
80    let msg_len = u16::from_be_bytes([pkt[2], pkt[3]]) as usize;
81    let body_end = 20 + msg_len;
82    if pkt.len() < body_end {
83        return None;
84    }
85    let magic_cookie = u32::from_be_bytes([pkt[4], pkt[5], pkt[6], pkt[7]]);
86    if magic_cookie != STUN_MAGIC_COOKIE {
87        return None;
88    }
89    let mut tx_id = [0u8; 12];
90    tx_id.copy_from_slice(&pkt[8..20]);
91
92    // Iterate attributes. Each attribute: 2B type, 2B length, N bytes
93    // value, then padded to 4-byte boundary.
94    let mut off = 20;
95    while off + 4 <= body_end {
96        let attr_type = u16::from_be_bytes([pkt[off], pkt[off + 1]]);
97        let attr_len = u16::from_be_bytes([pkt[off + 2], pkt[off + 3]]) as usize;
98        let val_start = off + 4;
99        let val_end = val_start + attr_len;
100        if val_end > body_end {
101            return None;
102        }
103        match attr_type {
104            // XOR-MAPPED-ADDRESS (RFC 8489 §14.2) — preferred form.
105            // Layout: 1B reserved, 1B family (0x01 = IPv4, 0x02 = IPv6),
106            // 2B xor_port, then 4B (IPv4) or 16B (IPv6) xor_addr.
107            0x0020 if attr_len >= 8 => {
108                let family = pkt[val_start + 1];
109                let xor_port = u16::from_be_bytes([pkt[val_start + 2], pkt[val_start + 3]]);
110                let port = xor_port ^ ((STUN_MAGIC_COOKIE >> 16) as u16);
111                match family {
112                    0x01 => {
113                        let xor_addr = u32::from_be_bytes([
114                            pkt[val_start + 4],
115                            pkt[val_start + 5],
116                            pkt[val_start + 6],
117                            pkt[val_start + 7],
118                        ]);
119                        let addr = xor_addr ^ STUN_MAGIC_COOKIE;
120                        let ip = std::net::Ipv4Addr::from(addr.to_be_bytes());
121                        return Some((std::net::IpAddr::V4(ip), port));
122                    }
123                    0x02 if attr_len >= 20 => {
124                        // IPv6: 16-byte XOR address. First 4 bytes XOR'd
125                        // with the magic cookie, remaining 12 XOR'd with
126                        // the transaction ID.
127                        let mut octets = [0u8; 16];
128                        octets.copy_from_slice(&pkt[val_start + 4..val_start + 20]);
129                        let cookie_be = STUN_MAGIC_COOKIE.to_be_bytes();
130                        for i in 0..4 {
131                            octets[i] ^= cookie_be[i];
132                        }
133                        for i in 0..12 {
134                            octets[4 + i] ^= tx_id[i];
135                        }
136                        let ip = std::net::Ipv6Addr::from(octets);
137                        return Some((std::net::IpAddr::V6(ip), port));
138                    }
139                    _ => {}
140                }
141            }
142            // MAPPED-ADDRESS (legacy, RFC 3489) — same layout sans XOR.
143            0x0001 if attr_len >= 8 => {
144                let family = pkt[val_start + 1];
145                let port = u16::from_be_bytes([pkt[val_start + 2], pkt[val_start + 3]]);
146                match family {
147                    0x01 => {
148                        let ip = std::net::Ipv4Addr::new(
149                            pkt[val_start + 4],
150                            pkt[val_start + 5],
151                            pkt[val_start + 6],
152                            pkt[val_start + 7],
153                        );
154                        return Some((std::net::IpAddr::V4(ip), port));
155                    }
156                    0x02 if attr_len >= 20 => {
157                        let mut octets = [0u8; 16];
158                        octets.copy_from_slice(&pkt[val_start + 4..val_start + 20]);
159                        let ip = std::net::Ipv6Addr::from(octets);
160                        return Some((std::net::IpAddr::V6(ip), port));
161                    }
162                    _ => {}
163                }
164            }
165            _ => {}
166        }
167        // Advance to next attribute, respecting 4-byte alignment padding.
168        off = val_end;
169        if off % 4 != 0 {
170            off += 4 - (off % 4);
171        }
172    }
173    None
174}
175
176/// Generate a 12-byte transaction ID from time + a process-monotonic
177/// counter + PID. Back-to-back calls within the same nanosecond MUST
178/// produce different IDs — the counter is what guarantees that (time
179/// alone wasn't enough; modern CPUs can issue many calls per nanosecond
180/// granularity, observed empirically in the full test suite).
181///
182/// Layout: bytes 0..8 = time nanos (low 64 bits), bytes 8..12 = atomic
183/// counter (low 32 bits). PID XOR'd into the time region so two
184/// concurrent processes that happen to issue at the same nanosecond
185/// with the same counter value still differ.
186fn fresh_tx_id() -> [u8; 12] {
187    use std::sync::atomic::{AtomicU64, Ordering};
188    static COUNTER: AtomicU64 = AtomicU64::new(0);
189
190    let mut id = [0u8; 12];
191    let nanos = std::time::SystemTime::now()
192        .duration_since(std::time::UNIX_EPOCH)
193        .map(|d| d.as_nanos())
194        .unwrap_or(0);
195    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
196    id[0..8].copy_from_slice(&(nanos as u64).to_le_bytes());
197    id[8..12].copy_from_slice(&(counter as u32).to_le_bytes());
198    let pid = std::process::id();
199    let pid_be = pid.to_le_bytes();
200    for i in 0..4 {
201        id[i] ^= pid_be[i];
202    }
203    id
204}
205
206/// Query a STUN server via socket `id`. Returns `(public_ip, public_port)`
207/// the server reports it saw, or `None` on timeout / parse failure. The
208/// returned IP is `IpAddr::V4` or `IpAddr::V6` depending on the server's
209/// XOR-MAPPED-ADDRESS family.
210pub fn stun_query(
211    socket_id: u64,
212    stun_host: &str,
213    stun_port: u16,
214    timeout: Duration,
215) -> Option<(std::net::IpAddr, u16)> {
216    let socket = udp_sockets::get(socket_id)?;
217    let addr = udp_sockets::resolve_one(stun_host, stun_port)?;
218    let tx_id = fresh_tx_id();
219    let pkt = build_binding_request(&tx_id);
220    socket.send_to(&pkt, addr).ok()?;
221    socket.set_read_timeout(Some(timeout)).ok()?;
222    let mut buf = [0u8; 1024];
223    loop {
224        let (n, src) = socket.recv_from(&mut buf).ok()?;
225        // Only accept responses from the STUN server we queried, and
226        // only if the response's tx_id matches ours (defends against
227        // races if the script is also exchanging traffic on this socket).
228        if src != addr {
229            continue;
230        }
231        if n < 20 || buf[8..20] != tx_id {
232            continue;
233        }
234        return parse_binding_response(&buf[..n]);
235    }
236}
237
238/// Default STUN servers used by `classify_nat` when the caller doesn't
239/// supply a list. Mix of Google + Cloudflare + Nextcloud means at least
240/// one survives any single-provider outage; the diverse paths make
241/// symmetric-NAT detection more reliable than a same-provider trio
242/// (some symmetric NATs use destination-IP-buckets, so two STUN servers
243/// in the same /24 may produce matching ports even on a symmetric NAT).
244pub const DEFAULT_STUN_SERVERS: &[(&str, u16)] = &[
245    ("stun.l.google.com", 19302),
246    ("stun.cloudflare.com", 3478),
247    ("stun.nextcloud.com", 443),
248];
249
250/// Per-server STUN observation collected during NAT classification.
251#[derive(Debug, Clone)]
252pub struct StunObservation {
253    /// `server` field.
254    pub server: String,
255    /// `ok` field.
256    pub ok: bool,
257    /// `public_ip` field.
258    pub public_ip: Option<std::net::IpAddr>,
259    /// `public_port` field.
260    pub public_port: Option<u16>,
261}
262
263/// Result of NAT classification.
264///
265/// `nat_type` interpretation:
266///   * `"cone"`      — all responding servers reported the SAME public
267///                     port → the NAT uses a single mapping per source
268///                     socket regardless of destination. `punch` will
269///                     work to any peer.
270///   * `"symmetric"` — responding servers reported DIFFERENT public
271///                     ports → the NAT allocates a fresh public port per
272///                     destination. `punch` will FAIL because the port
273///                     the peer punches at (learned from one STUN
274///                     server) won't be the port your traffic to them
275///                     uses. Requires a TURN relay to recover.
276///   * `"unknown"`   — fewer than 2 servers responded → can't classify;
277///                     don't draw conclusions.
278#[derive(Debug, Clone)]
279pub struct NatClassification {
280    /// `nat_type` field.
281    pub nat_type: &'static str,
282    /// `public_ip` field.
283    pub public_ip: Option<std::net::IpAddr>,
284    /// `observations` field.
285    pub observations: Vec<StunObservation>,
286    /// `queried` field.
287    pub queried: u32,
288    /// `succeeded` field.
289    pub succeeded: u32,
290}
291
292/// Query multiple STUN servers via the SAME socket and classify the NAT
293/// based on whether the reported public ports match across servers. All
294/// queries must use the same socket so we're testing the SAME NAT
295/// mapping behaviour across destinations — that's the whole point.
296pub fn classify_nat(
297    socket_id: u64,
298    servers: &[(&str, u16)],
299    timeout: Duration,
300) -> NatClassification {
301    let mut observations: Vec<StunObservation> = Vec::with_capacity(servers.len());
302    let mut ports_seen: Vec<u16> = Vec::with_capacity(servers.len());
303    let mut ip_seen: Option<std::net::IpAddr> = None;
304
305    for (host, port) in servers {
306        let result = stun_query(socket_id, host, *port, timeout);
307        let observation = match result {
308            Some((ip, p)) => {
309                ports_seen.push(p);
310                // The public IP should be identical across all servers
311                // (it's our outbound interface IP). If they disagree
312                // something weird is happening (multi-WAN load balancing?)
313                // — record the first.
314                if ip_seen.is_none() {
315                    ip_seen = Some(ip);
316                }
317                StunObservation {
318                    server: format!("{}:{}", host, port),
319                    ok: true,
320                    public_ip: Some(ip),
321                    public_port: Some(p),
322                }
323            }
324            None => StunObservation {
325                server: format!("{}:{}", host, port),
326                ok: false,
327                public_ip: None,
328                public_port: None,
329            },
330        };
331        observations.push(observation);
332    }
333
334    let queried = servers.len() as u32;
335    let succeeded = ports_seen.len() as u32;
336    let nat_type = if succeeded < 2 {
337        "unknown"
338    } else if ports_seen.iter().all(|p| *p == ports_seen[0]) {
339        "cone"
340    } else {
341        "symmetric"
342    };
343
344    NatClassification {
345        nat_type,
346        public_ip: ip_seen,
347        observations,
348        queried,
349        succeeded,
350    }
351}
352
353/// Result of a hole-punch attempt.
354#[derive(Debug, Clone)]
355pub struct PunchResult {
356    /// `established` field.
357    pub established: bool,
358    /// `latency_ms` field.
359    pub latency_ms: u64,
360    /// `bombards_sent` field.
361    pub bombards_sent: u32,
362    /// `peer_msg` field.
363    pub peer_msg: Option<Vec<u8>>,
364    /// `peer_addr` field.
365    pub peer_addr: Option<SocketAddr>,
366}
367
368/// Bombard the peer's `ip:port` at `interval` until we receive any
369/// datagram on the socket (= bidirectional flow established) OR `timeout`
370/// elapses (= NAT punch failed — both peers' NATs are likely too strict).
371///
372/// Sends a small probe payload each bombard. Receivers should treat the
373/// FIRST inbound datagram on a hole-punched socket as the connection
374/// confirmation; subsequent application traffic flows normally via
375/// `udp_send_to` / `udp_recv` on the same socket.
376pub fn hole_punch(
377    socket_id: u64,
378    peer_host: &str,
379    peer_port: u16,
380    timeout: Duration,
381    interval: Duration,
382    probe_payload: &[u8],
383) -> PunchResult {
384    let mut result = PunchResult {
385        established: false,
386        latency_ms: 0,
387        bombards_sent: 0,
388        peer_msg: None,
389        peer_addr: None,
390    };
391    let Some(socket) = udp_sockets::get(socket_id) else {
392        return result;
393    };
394    let Some(peer_addr) = udp_sockets::resolve_one(peer_host, peer_port) else {
395        return result;
396    };
397    let start = Instant::now();
398    let deadline = start + timeout;
399    // Short per-loop recv timeout so we bombard at roughly `interval` rate.
400    let recv_timeout = std::cmp::min(interval, Duration::from_millis(50));
401    if socket.set_read_timeout(Some(recv_timeout)).is_err() {
402        return result;
403    }
404    let mut buf = [0u8; 65_535];
405    while Instant::now() < deadline {
406        if socket.send_to(probe_payload, peer_addr).is_ok() {
407            result.bombards_sent += 1;
408        }
409        match socket.recv_from(&mut buf) {
410            Ok((n, src)) => {
411                // Accept the first inbound — even if it's not from the
412                // expected peer ip:port (a NAT might rewrite the src
413                // port). Application code can verify identity at higher
414                // protocol level.
415                result.established = true;
416                result.latency_ms = start.elapsed().as_millis() as u64;
417                result.peer_msg = Some(buf[..n].to_vec());
418                result.peer_addr = Some(src);
419                return result;
420            }
421            Err(_) => {
422                // Timeout / would-block — loop and bombard again.
423            }
424        }
425        std::thread::sleep(interval);
426    }
427    result.latency_ms = start.elapsed().as_millis() as u64;
428    result
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn binding_request_packet_shape() {
437        let tx = [0u8; 12];
438        let pkt = build_binding_request(&tx);
439        assert_eq!(pkt.len(), 20);
440        assert_eq!(&pkt[0..2], &[0x00, 0x01], "msg type = Binding Request");
441        assert_eq!(&pkt[2..4], &[0x00, 0x00], "msg length = 0");
442        assert_eq!(&pkt[4..8], &STUN_MAGIC_COOKIE.to_be_bytes(), "magic cookie");
443        assert_eq!(&pkt[8..20], &tx, "transaction ID");
444    }
445
446    #[test]
447    fn parse_xor_mapped_address_round_trip() {
448        // Build a synthetic Binding Response with XOR-MAPPED-ADDRESS for
449        // 203.0.113.45:51234 and verify the parser recovers it.
450        let real_ip = std::net::Ipv4Addr::new(203, 0, 113, 45);
451        let real_port: u16 = 51234;
452        let xor_port = real_port ^ ((STUN_MAGIC_COOKIE >> 16) as u16);
453        let real_ip_u32 = u32::from_be_bytes(real_ip.octets());
454        let xor_addr = real_ip_u32 ^ STUN_MAGIC_COOKIE;
455
456        let mut pkt = vec![0u8; 32];
457        pkt[0..2].copy_from_slice(&0x0101u16.to_be_bytes()); // Binding Response
458        pkt[2..4].copy_from_slice(&12u16.to_be_bytes()); // 12 bytes of attrs
459        pkt[4..8].copy_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
460        // tx id 8..20 stays zero
461        // Attribute at 20: XOR-MAPPED-ADDRESS, 8-byte value
462        pkt[20..22].copy_from_slice(&0x0020u16.to_be_bytes());
463        pkt[22..24].copy_from_slice(&8u16.to_be_bytes());
464        pkt[24] = 0x00; // reserved
465        pkt[25] = 0x01; // family IPv4
466        pkt[26..28].copy_from_slice(&xor_port.to_be_bytes());
467        pkt[28..32].copy_from_slice(&xor_addr.to_be_bytes());
468
469        let parsed = parse_binding_response(&pkt).expect("must parse");
470        assert_eq!(parsed.0, std::net::IpAddr::V4(real_ip));
471        assert_eq!(parsed.1, real_port);
472    }
473
474    /// IPv6 XOR-MAPPED-ADDRESS: 16-byte address scrambled with magic-
475    /// cookie || tx_id. Pin the full round-trip so future RFC 8489 reads
476    /// don't accidentally regress the encoding.
477    #[test]
478    fn parse_xor_mapped_address_ipv6_round_trip() {
479        let real_ip = std::net::Ipv6Addr::new(
480            0x2001, 0x0db8, 0xdead, 0xbeef, 0xcafe, 0xface, 0x1234, 0x5678,
481        );
482        let real_port: u16 = 51234;
483        let tx_id: [u8; 12] = [
484            0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
485        ];
486        let xor_port = real_port ^ ((STUN_MAGIC_COOKIE >> 16) as u16);
487        let mut xor_addr = real_ip.octets();
488        let cookie_be = STUN_MAGIC_COOKIE.to_be_bytes();
489        for i in 0..4 {
490            xor_addr[i] ^= cookie_be[i];
491        }
492        for i in 0..12 {
493            xor_addr[4 + i] ^= tx_id[i];
494        }
495
496        let mut pkt = vec![0u8; 44]; // 20 header + 4 attr_hdr + 20 attr_val
497        pkt[0..2].copy_from_slice(&0x0101u16.to_be_bytes());
498        pkt[2..4].copy_from_slice(&24u16.to_be_bytes()); // 4 + 20 = 24 bytes of attrs
499        pkt[4..8].copy_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
500        pkt[8..20].copy_from_slice(&tx_id);
501        pkt[20..22].copy_from_slice(&0x0020u16.to_be_bytes());
502        pkt[22..24].copy_from_slice(&20u16.to_be_bytes()); // attr len = 20
503        pkt[24] = 0x00; // reserved
504        pkt[25] = 0x02; // family IPv6
505        pkt[26..28].copy_from_slice(&xor_port.to_be_bytes());
506        pkt[28..44].copy_from_slice(&xor_addr);
507
508        let parsed = parse_binding_response(&pkt).expect("must parse IPv6 XOR-MAPPED");
509        assert_eq!(parsed.0, std::net::IpAddr::V6(real_ip));
510        assert_eq!(parsed.1, real_port);
511    }
512
513    /// RFC 8489 §14: "Clients MUST ignore comprehension-optional
514    /// attributes they don't understand". Pin via a Binding Response
515    /// that has an unknown attribute (type 0x9001 — high bit set
516    /// indicates comprehension-optional per §14) sandwiched between
517    /// the header and the XOR-MAPPED-ADDRESS. Parser must skip the
518    /// junk attribute and still recover the address correctly.
519    ///
520    /// Catches a future regression where the attribute-walk loop
521    /// accidentally bails on unknown types instead of skipping them.
522    #[test]
523    fn parse_ignores_unknown_optional_attributes() {
524        let real_ip = std::net::Ipv4Addr::new(192, 0, 2, 99);
525        let real_port: u16 = 12345;
526        let xor_port = real_port ^ ((STUN_MAGIC_COOKIE >> 16) as u16);
527        let xor_addr = u32::from_be_bytes(real_ip.octets()) ^ STUN_MAGIC_COOKIE;
528
529        // Layout: header (20) + UNKNOWN attr (8) + XOR-MAPPED-ADDR (12)
530        // unknown attr: type=0x9001 (comprehension-optional), len=4,
531        // value="JUNK"
532        let mut pkt = vec![0u8; 40];
533        pkt[0..2].copy_from_slice(&0x0101u16.to_be_bytes());
534        pkt[2..4].copy_from_slice(&20u16.to_be_bytes()); // 8 + 12 = 20 bytes of attrs
535        pkt[4..8].copy_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
536
537        // Unknown attr at offset 20.
538        pkt[20..22].copy_from_slice(&0x9001u16.to_be_bytes());
539        pkt[22..24].copy_from_slice(&4u16.to_be_bytes());
540        pkt[24..28].copy_from_slice(b"JUNK");
541
542        // XOR-MAPPED-ADDRESS at offset 28.
543        pkt[28..30].copy_from_slice(&0x0020u16.to_be_bytes());
544        pkt[30..32].copy_from_slice(&8u16.to_be_bytes());
545        pkt[32] = 0x00; // reserved
546        pkt[33] = 0x01; // family IPv4
547        pkt[34..36].copy_from_slice(&xor_port.to_be_bytes());
548        pkt[36..40].copy_from_slice(&xor_addr.to_be_bytes());
549
550        let parsed = parse_binding_response(&pkt).expect("must parse despite unknown attribute");
551        assert_eq!(parsed.0, std::net::IpAddr::V4(real_ip));
552        assert_eq!(parsed.1, real_port);
553    }
554
555    #[test]
556    fn parse_rejects_wrong_message_type() {
557        let mut pkt = [0u8; 20];
558        pkt[0] = 0x00;
559        pkt[1] = 0x02; // not Binding Response
560        pkt[4..8].copy_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
561        assert!(parse_binding_response(&pkt).is_none());
562    }
563
564    #[test]
565    fn parse_rejects_wrong_magic_cookie() {
566        let mut pkt = [0u8; 20];
567        pkt[0..2].copy_from_slice(&0x0101u16.to_be_bytes());
568        pkt[4..8].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]);
569        assert!(parse_binding_response(&pkt).is_none());
570    }
571
572    #[test]
573    fn parse_rejects_truncated_packet() {
574        assert!(parse_binding_response(&[0u8; 4]).is_none());
575        assert!(parse_binding_response(&[]).is_none());
576    }
577
578    #[test]
579    fn fresh_tx_id_is_12_bytes() {
580        let id = fresh_tx_id();
581        assert_eq!(id.len(), 12);
582        // Two consecutive calls must differ — same-nanosecond collision
583        // chance is vanishingly small; if this fails the test rig is wrong.
584        let id2 = fresh_tx_id();
585        assert_ne!(id, id2, "two tx ids should differ — random source broken");
586    }
587
588    /// In-process fake STUN server: bind a UdpSocket, accept one Binding
589    /// Request, reply with a synthetic Binding Response claiming the
590    /// requester is at 198.51.100.7:50001. Verifies the full client path:
591    /// build → send → recv → parse.
592    #[test]
593    fn stun_query_against_local_fake_server() {
594        use std::net::UdpSocket;
595        use std::thread;
596
597        let server = UdpSocket::bind("127.0.0.1:0").expect("bind fake stun");
598        let server_addr = server.local_addr().unwrap();
599        thread::spawn(move || {
600            let mut buf = [0u8; 1024];
601            let (n, src) = server.recv_from(&mut buf).expect("recv req");
602            // Echo tx_id back in a synthetic response advertising
603            // 198.51.100.7:50001.
604            let tx_id = &buf[8..20];
605            let claim_ip = std::net::Ipv4Addr::new(198, 51, 100, 7);
606            let claim_port: u16 = 50001;
607            let xor_port = claim_port ^ ((STUN_MAGIC_COOKIE >> 16) as u16);
608            let claim_ip_u32 = u32::from_be_bytes(claim_ip.octets());
609            let xor_addr = claim_ip_u32 ^ STUN_MAGIC_COOKIE;
610            let mut resp = vec![0u8; 32];
611            resp[0..2].copy_from_slice(&0x0101u16.to_be_bytes());
612            resp[2..4].copy_from_slice(&12u16.to_be_bytes());
613            resp[4..8].copy_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
614            resp[8..20].copy_from_slice(tx_id);
615            resp[20..22].copy_from_slice(&0x0020u16.to_be_bytes());
616            resp[22..24].copy_from_slice(&8u16.to_be_bytes());
617            resp[24] = 0x00;
618            resp[25] = 0x01;
619            resp[26..28].copy_from_slice(&xor_port.to_be_bytes());
620            resp[28..32].copy_from_slice(&xor_addr.to_be_bytes());
621            let _ = server.send_to(&resp, src);
622            assert!(n >= 20, "request must be ≥ 20 bytes");
623        });
624
625        let client = udp_sockets::open("127.0.0.1", 0).expect("bind client");
626        let result = stun_query(
627            client,
628            &server_addr.ip().to_string(),
629            server_addr.port(),
630            Duration::from_secs(2),
631        );
632        let (ip, port) = result.expect("STUN query must round-trip");
633        assert_eq!(
634            ip,
635            std::net::IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 7))
636        );
637        assert_eq!(port, 50001);
638        udp_sockets::close(client);
639    }
640
641    /// Multi-server NAT classification using TWO in-process fake STUN
642    /// servers that DISAGREE on the port they report. This is the
643    /// "symmetric NAT" signature: same socket, different destinations,
644    /// different reported public ports. Pins the classifier's logic
645    /// end-to-end through the real STUN protocol path.
646    #[test]
647    fn classify_nat_detects_symmetric_when_servers_report_different_ports() {
648        use std::net::UdpSocket;
649        use std::thread;
650
651        // Helper: spin up a fake STUN that always claims the requester
652        // is at `claim_ip:claim_port`. Returns server's bound addr.
653        fn spawn_fake_stun(claim_port: u16) -> std::net::SocketAddr {
654            let server = UdpSocket::bind("127.0.0.1:0").expect("bind fake");
655            let addr = server.local_addr().unwrap();
656            thread::spawn(move || {
657                let mut buf = [0u8; 1024];
658                let (_, src) = server.recv_from(&mut buf).expect("recv");
659                let tx_id = &buf[8..20];
660                let claim_ip = std::net::Ipv4Addr::new(198, 51, 100, 1);
661                let xor_port = claim_port ^ ((STUN_MAGIC_COOKIE >> 16) as u16);
662                let xor_addr = u32::from_be_bytes(claim_ip.octets()) ^ STUN_MAGIC_COOKIE;
663                let mut resp = vec![0u8; 32];
664                resp[0..2].copy_from_slice(&0x0101u16.to_be_bytes());
665                resp[2..4].copy_from_slice(&12u16.to_be_bytes());
666                resp[4..8].copy_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
667                resp[8..20].copy_from_slice(tx_id);
668                resp[20..22].copy_from_slice(&0x0020u16.to_be_bytes());
669                resp[22..24].copy_from_slice(&8u16.to_be_bytes());
670                resp[24] = 0x00;
671                resp[25] = 0x01;
672                resp[26..28].copy_from_slice(&xor_port.to_be_bytes());
673                resp[28..32].copy_from_slice(&xor_addr.to_be_bytes());
674                let _ = server.send_to(&resp, src);
675            });
676            addr
677        }
678
679        let a = spawn_fake_stun(40001);
680        let b = spawn_fake_stun(40002); // DIFFERENT port → symmetric signature
681
682        let client = udp_sockets::open("127.0.0.1", 0).expect("bind client");
683        let a_host = a.ip().to_string();
684        let b_host = b.ip().to_string();
685        let servers = [(a_host.as_str(), a.port()), (b_host.as_str(), b.port())];
686        let result = classify_nat(client, &servers, Duration::from_secs(2));
687        udp_sockets::close(client);
688
689        assert_eq!(result.nat_type, "symmetric");
690        assert_eq!(result.queried, 2);
691        assert_eq!(result.succeeded, 2);
692        assert_eq!(result.observations.len(), 2);
693        assert_eq!(
694            result.public_ip,
695            Some(std::net::IpAddr::V4(std::net::Ipv4Addr::new(
696                198, 51, 100, 1
697            )))
698        );
699    }
700
701    /// Two fake STUN servers that REPORT THE SAME port → cone NAT.
702    #[test]
703    fn classify_nat_detects_cone_when_servers_agree() {
704        use std::net::UdpSocket;
705        use std::thread;
706        fn spawn(claim_port: u16) -> std::net::SocketAddr {
707            let s = UdpSocket::bind("127.0.0.1:0").unwrap();
708            let a = s.local_addr().unwrap();
709            thread::spawn(move || {
710                let mut buf = [0u8; 1024];
711                let (_, src) = s.recv_from(&mut buf).unwrap();
712                let tx_id = &buf[8..20];
713                let claim_ip = std::net::Ipv4Addr::new(198, 51, 100, 9);
714                let xp = claim_port ^ ((STUN_MAGIC_COOKIE >> 16) as u16);
715                let xa = u32::from_be_bytes(claim_ip.octets()) ^ STUN_MAGIC_COOKIE;
716                let mut r = vec![0u8; 32];
717                r[0..2].copy_from_slice(&0x0101u16.to_be_bytes());
718                r[2..4].copy_from_slice(&12u16.to_be_bytes());
719                r[4..8].copy_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
720                r[8..20].copy_from_slice(tx_id);
721                r[20..22].copy_from_slice(&0x0020u16.to_be_bytes());
722                r[22..24].copy_from_slice(&8u16.to_be_bytes());
723                r[24] = 0x00;
724                r[25] = 0x01;
725                r[26..28].copy_from_slice(&xp.to_be_bytes());
726                r[28..32].copy_from_slice(&xa.to_be_bytes());
727                let _ = s.send_to(&r, src);
728            });
729            a
730        }
731        let a = spawn(45000);
732        let b = spawn(45000); // SAME port → cone
733        let client = udp_sockets::open("127.0.0.1", 0).unwrap();
734        let aip = a.ip().to_string();
735        let bip = b.ip().to_string();
736        let servers = [(aip.as_str(), a.port()), (bip.as_str(), b.port())];
737        let result = classify_nat(client, &servers, Duration::from_secs(2));
738        udp_sockets::close(client);
739        assert_eq!(result.nat_type, "cone");
740        assert_eq!(result.succeeded, 2);
741    }
742
743    /// Only one STUN server responded → can't classify, returns
744    /// "unknown". Important contract: the caller shouldn't conclude
745    /// "punch will work" from a single-server result.
746    #[test]
747    fn classify_nat_returns_unknown_when_only_one_server_responds() {
748        use std::net::UdpSocket;
749        use std::thread;
750        // One working fake.
751        let s = UdpSocket::bind("127.0.0.1:0").unwrap();
752        let working = s.local_addr().unwrap();
753        thread::spawn(move || {
754            let mut buf = [0u8; 1024];
755            let (_, src) = s.recv_from(&mut buf).unwrap();
756            let tx_id = &buf[8..20];
757            let claim_ip = std::net::Ipv4Addr::new(198, 51, 100, 9);
758            let xp = 12345u16 ^ ((STUN_MAGIC_COOKIE >> 16) as u16);
759            let xa = u32::from_be_bytes(claim_ip.octets()) ^ STUN_MAGIC_COOKIE;
760            let mut r = vec![0u8; 32];
761            r[0..2].copy_from_slice(&0x0101u16.to_be_bytes());
762            r[2..4].copy_from_slice(&12u16.to_be_bytes());
763            r[4..8].copy_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
764            r[8..20].copy_from_slice(tx_id);
765            r[20..22].copy_from_slice(&0x0020u16.to_be_bytes());
766            r[22..24].copy_from_slice(&8u16.to_be_bytes());
767            r[24] = 0x00;
768            r[25] = 0x01;
769            r[26..28].copy_from_slice(&xp.to_be_bytes());
770            r[28..32].copy_from_slice(&xa.to_be_bytes());
771            let _ = s.send_to(&r, src);
772        });
773
774        // Silent fake (bind, never reply).
775        let silent_sock = UdpSocket::bind("127.0.0.1:0").unwrap();
776        let silent = silent_sock.local_addr().unwrap();
777        drop(silent_sock);
778
779        let client = udp_sockets::open("127.0.0.1", 0).unwrap();
780        let wip = working.ip().to_string();
781        let sip = silent.ip().to_string();
782        let servers = [
783            (wip.as_str(), working.port()),
784            (sip.as_str(), silent.port()),
785        ];
786        let result = classify_nat(client, &servers, Duration::from_millis(300));
787        udp_sockets::close(client);
788        assert_eq!(result.nat_type, "unknown");
789        assert_eq!(result.queried, 2);
790        assert_eq!(result.succeeded, 1, "exactly one server responded");
791    }
792
793    /// Two pool sockets bombard each other → first to receive returns
794    /// established=true. Mirrors the v1 punch state machine on loopback.
795    #[test]
796    fn hole_punch_loopback_establishes() {
797        let a = udp_sockets::open("127.0.0.1", 0).expect("bind a");
798        let b = udp_sockets::open("127.0.0.1", 0).expect("bind b");
799        let a_addr = udp_sockets::local_addr(a).unwrap();
800        let b_addr = udp_sockets::local_addr(b).unwrap();
801
802        // Run B's punch on a background thread aimed at A; main thread
803        // runs A's punch aimed at B. Both should establish within ~50ms.
804        let b_addr_str = b_addr.ip().to_string();
805        let a_addr_str = a_addr.ip().to_string();
806        let b_handle = std::thread::spawn(move || {
807            hole_punch(
808                b,
809                &a_addr_str,
810                a_addr.port(),
811                Duration::from_secs(2),
812                Duration::from_millis(20),
813                b"hi from b",
814            )
815        });
816        let a_result = hole_punch(
817            a,
818            &b_addr_str,
819            b_addr.port(),
820            Duration::from_secs(2),
821            Duration::from_millis(20),
822            b"hi from a",
823        );
824        let b_result = b_handle.join().expect("thread");
825        assert!(a_result.established, "a should see b's bombard");
826        assert!(b_result.established, "b should see a's bombard");
827        assert!(a_result.bombards_sent >= 1);
828        assert!(b_result.bombards_sent >= 1);
829        assert!(a_result.peer_msg.is_some());
830        assert!(b_result.peer_msg.is_some());
831
832        udp_sockets::close(a);
833        udp_sockets::close(b);
834    }
835}