Skip to main content

seyd_nat/
stun.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! STUN client (RFC 5389) on a socket we own, plus NAT classification.
4//!
5//! Two servers on different operators are queried so the answers can be
6//! compared: the same `ip:port` from both means a cone NAT and a usable
7//! reflexive candidate; the same IP with different ports means the mapping is
8//! chosen per destination — symmetric — and the port STUN saw is not the port
9//! the pilot would arrive on. Anycast within one provider could hide that,
10//! which is why two operators rather than one.
11
12use rand::RngCore;
13use serde::Serialize;
14use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket};
15use std::time::{Duration, Instant};
16
17pub const STUN_SERVERS: [(&str, u16); 2] =
18    [("stun.l.google.com", 19302), ("stun.cloudflare.com", 3478)];
19
20const MAGIC: u32 = 0x2112_A442;
21const BINDING_REQUEST: u16 = 0x0001;
22const BINDING_RESPONSE: u16 = 0x0101;
23const XOR_MAPPED_ADDRESS: u16 = 0x0020;
24
25/// Compressed RFC 5389 retransmission ladder. The full schedule runs to
26/// 39.5 s, far longer than any pilot will wait for us.
27const RETRANSMIT_DELAYS: [Duration; 4] = [
28    Duration::ZERO,
29    Duration::from_millis(250),
30    Duration::from_millis(500),
31    Duration::from_millis(1000),
32];
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "kebab-case")]
36pub enum NatType {
37    Cone,
38    Symmetric,
39    Unknown,
40}
41
42/// What STUN could work out about our position behind the NAT.
43#[derive(Debug, Clone, Default)]
44pub struct NatDiscovery {
45    /// External `ip:port`, only when both servers agreed.
46    pub reflexive: Option<SocketAddr>,
47    pub observed: Vec<SocketAddr>,
48    pub symmetric: bool,
49}
50
51impl NatDiscovery {
52    pub fn nat_type(&self) -> NatType {
53        if self.symmetric {
54            NatType::Symmetric
55        } else if self.reflexive.is_some() {
56            NatType::Cone
57        } else {
58            NatType::Unknown
59        }
60    }
61}
62
63/// Encode a Binding Request with the given 12-byte transaction id.
64pub fn encode_binding_request(txid: &[u8; 12]) -> [u8; 20] {
65    let mut out = [0u8; 20];
66    out[0..2].copy_from_slice(&BINDING_REQUEST.to_be_bytes());
67    out[4..8].copy_from_slice(&MAGIC.to_be_bytes());
68    out[8..20].copy_from_slice(txid);
69    out
70}
71
72/// Extract XOR-MAPPED-ADDRESS from a Binding Success Response for `txid`.
73pub fn parse_binding_response(data: &[u8], txid: &[u8; 12]) -> Option<SocketAddr> {
74    if data.len() < 20 {
75        return None;
76    }
77    let msg_type = u16::from_be_bytes([data[0], data[1]]);
78    let magic = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
79    if magic != MAGIC || msg_type != BINDING_RESPONSE || &data[8..20] != txid {
80        return None;
81    }
82    let mut off = 20;
83    while off + 4 <= data.len() {
84        let attr_type = u16::from_be_bytes([data[off], data[off + 1]]);
85        let attr_len = u16::from_be_bytes([data[off + 2], data[off + 3]]) as usize;
86        off += 4;
87        let attr = data.get(off..off + attr_len)?;
88        off += (attr_len + 3) & !3; // attributes are 4-byte aligned
89        if attr_type != XOR_MAPPED_ADDRESS || attr.len() < 8 {
90            continue;
91        }
92        let family = attr[1];
93        let port = u16::from_be_bytes([attr[2], attr[3]]) ^ (MAGIC >> 16) as u16;
94        match family {
95            0x01 => {
96                let ip = u32::from_be_bytes([attr[4], attr[5], attr[6], attr[7]]) ^ MAGIC;
97                return Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::from(ip)), port));
98            }
99            0x02 if attr.len() >= 20 => {
100                // IPv6 is XORed with the magic cookie followed by the transaction id.
101                let mut mask = [0u8; 16];
102                mask[..4].copy_from_slice(&MAGIC.to_be_bytes());
103                mask[4..].copy_from_slice(txid);
104                let mut raw = [0u8; 16];
105                for i in 0..16 {
106                    raw[i] = attr[4 + i] ^ mask[i];
107                }
108                return Some(SocketAddr::new(IpAddr::V6(Ipv6Addr::from(raw)), port));
109            }
110            _ => {}
111        }
112    }
113    None
114}
115
116/// Build a Binding Success Response carrying `addr` (test helper and useful
117/// for a future in-process STUN server).
118pub fn encode_binding_response(txid: &[u8; 12], addr: SocketAddr) -> Vec<u8> {
119    let mut attr = Vec::new();
120    let xport = addr.port() ^ (MAGIC >> 16) as u16;
121    match addr.ip() {
122        IpAddr::V4(v4) => {
123            attr.extend_from_slice(&[0, 0x01]);
124            attr.extend_from_slice(&xport.to_be_bytes());
125            attr.extend_from_slice(&(u32::from(v4) ^ MAGIC).to_be_bytes());
126        }
127        IpAddr::V6(v6) => {
128            attr.extend_from_slice(&[0, 0x02]);
129            attr.extend_from_slice(&xport.to_be_bytes());
130            let mut mask = [0u8; 16];
131            mask[..4].copy_from_slice(&MAGIC.to_be_bytes());
132            mask[4..].copy_from_slice(txid);
133            attr.extend(v6.octets().iter().zip(mask).map(|(a, b)| a ^ b));
134        }
135    }
136    let mut out = Vec::with_capacity(20 + 4 + attr.len());
137    out.extend_from_slice(&BINDING_RESPONSE.to_be_bytes());
138    out.extend_from_slice(&((4 + attr.len()) as u16).to_be_bytes());
139    out.extend_from_slice(&MAGIC.to_be_bytes());
140    out.extend_from_slice(txid);
141    out.extend_from_slice(&XOR_MAPPED_ADDRESS.to_be_bytes());
142    out.extend_from_slice(&(attr.len() as u16).to_be_bytes());
143    out.extend_from_slice(&attr);
144    out
145}
146
147async fn resolve(host: &str, port: u16, want_v6: bool) -> Option<SocketAddr> {
148    let addrs = tokio::net::lookup_host((host, port)).await.ok()?;
149    addrs.into_iter().find(|a| a.is_ipv6() == want_v6)
150}
151
152/// One Binding Request with retransmission on `sock`; returns the reflexive address.
153async fn binding_transaction(
154    sock: &tokio::net::UdpSocket,
155    server: (&str, u16),
156    timeout: Duration,
157) -> Option<SocketAddr> {
158    let want_v6 = sock.local_addr().ok()?.is_ipv6();
159    let server_addr = match resolve(server.0, server.1, want_v6).await {
160        Some(a) => a,
161        None => {
162            tracing::debug!("STUN DNS lookup for {} failed", server.0);
163            return None;
164        }
165    };
166    let mut txid = [0u8; 12];
167    rand::thread_rng().fill_bytes(&mut txid);
168    let request = encode_binding_request(&txid);
169    let deadline = Instant::now() + timeout;
170    let mut buf = [0u8; 1024];
171
172    for (i, delay) in RETRANSMIT_DELAYS.iter().enumerate() {
173        if let Err(e) = sock.send_to(&request, server_addr).await {
174            tracing::debug!("STUN send to {} failed: {e}", server.0);
175            return None;
176        }
177        // Wait for a matching reply until the next retransmit (or the deadline).
178        let wait_until = if i + 1 < RETRANSMIT_DELAYS.len() {
179            (Instant::now() + RETRANSMIT_DELAYS[i + 1] - *delay).min(deadline)
180        } else {
181            deadline
182        };
183        loop {
184            let now = Instant::now();
185            if now >= wait_until {
186                break;
187            }
188            match tokio::time::timeout(wait_until - now, sock.recv_from(&mut buf)).await {
189                Ok(Ok((n, _))) => {
190                    if let Some(addr) = parse_binding_response(&buf[..n], &txid) {
191                        return Some(addr);
192                    }
193                    // Unrelated datagram (QUIC probe, other STUN txid): keep waiting.
194                }
195                Ok(Err(_)) | Err(_) => break,
196            }
197        }
198        if Instant::now() >= deadline {
199            break;
200        }
201    }
202    tracing::debug!("STUN timed out against {}", server.0);
203    None
204}
205
206/// Query every STUN server from `sock` and classify what we see.
207///
208/// `sock` must be bound and non-blocking, and must be the same socket the
209/// QUIC server will go on to use — that is the whole point. The socket is
210/// duplicated for the duration of the call; the original is never touched.
211pub async fn discover(sock: &UdpSocket) -> NatDiscovery {
212    discover_with_timeout(sock, Duration::from_millis(2500)).await
213}
214
215pub async fn discover_with_timeout(sock: &UdpSocket, timeout: Duration) -> NatDiscovery {
216    let mut result = NatDiscovery::default();
217    let dup = match sock.try_clone().and_then(tokio::net::UdpSocket::from_std) {
218        Ok(s) => s,
219        Err(e) => {
220            tracing::warn!("STUN: cannot register socket with the runtime: {e}");
221            return result;
222        }
223    };
224    for server in STUN_SERVERS {
225        if let Some(addr) = binding_transaction(&dup, server, timeout).await {
226            tracing::debug!("STUN {} reports {addr}", server.0);
227            result.observed.push(addr);
228        }
229    }
230    if result.observed.is_empty() {
231        tracing::warn!(
232            "STUN failed against all {} servers — no reflexive candidate",
233            STUN_SERVERS.len()
234        );
235        return result;
236    }
237    let first = result.observed[0];
238    if result.observed.iter().all(|a| *a == first) {
239        result.reflexive = Some(first);
240    } else if result.observed.iter().all(|a| a.ip() == first.ip()) {
241        // Same external IP, different ports: textbook symmetric NAT.
242        result.symmetric = true;
243        tracing::info!(
244            "symmetric NAT detected ({:?}) — reflexive candidate is unusable",
245            result.observed
246        );
247    } else {
248        // Different external IPs — multiple WAN links or a load-balanced CGNAT.
249        result.symmetric = true;
250        tracing::info!(
251            "inconsistent external addresses from STUN ({:?}) — treating as symmetric",
252            result.observed
253        );
254    }
255    result
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn request_layout() {
264        let txid = [7u8; 12];
265        let r = encode_binding_request(&txid);
266        assert_eq!(&r[..8], &[0x00, 0x01, 0x00, 0x00, 0x21, 0x12, 0xa4, 0x42]);
267        assert_eq!(&r[8..], &txid);
268    }
269
270    #[test]
271    fn v4_response_roundtrip_and_known_bytes() {
272        let txid = [0xaa; 12];
273        let addr: SocketAddr = "203.0.113.5:4433".parse().unwrap();
274        let resp = encode_binding_response(&txid, addr);
275        // attribute: type 0x0020, len 8, family 1, xport, xaddr
276        assert_eq!(&resp[20..24], &[0x00, 0x20, 0x00, 0x08]);
277        assert_eq!(resp[25], 0x01);
278        assert_eq!(u16::from_be_bytes([resp[26], resp[27]]), 4433 ^ 0x2112);
279        assert_eq!(parse_binding_response(&resp, &txid), Some(addr));
280        assert_eq!(
281            parse_binding_response(&resp, &[0xbb; 12]),
282            None,
283            "txid mismatch"
284        );
285        let mut wrong = resp.clone();
286        wrong[1] = 0x11;
287        assert_eq!(
288            parse_binding_response(&wrong, &txid),
289            None,
290            "error response"
291        );
292    }
293
294    #[test]
295    fn v6_response_roundtrip() {
296        let txid = [0x5c; 12];
297        let addr: SocketAddr = "[2606:4700::1111]:60001".parse().unwrap();
298        let resp = encode_binding_response(&txid, addr);
299        assert_eq!(parse_binding_response(&resp, &txid), Some(addr));
300    }
301
302    #[test]
303    fn skips_unknown_attributes() {
304        let txid = [1u8; 12];
305        let addr: SocketAddr = "8.8.8.8:53".parse().unwrap();
306        let mut resp = encode_binding_response(&txid, addr);
307        // Prepend a SOFTWARE attribute (0x8022) with 3-byte value padded to 4.
308        let extra = [0x80, 0x22, 0x00, 0x03, b'x', b'y', b'z', 0x00];
309        let mut with_extra = resp[..20].to_vec();
310        with_extra.extend_from_slice(&extra);
311        with_extra.extend_from_slice(&resp[20..]);
312        let len = (with_extra.len() - 20) as u16;
313        with_extra[2..4].copy_from_slice(&len.to_be_bytes());
314        resp = with_extra;
315        assert_eq!(parse_binding_response(&resp, &txid), Some(addr));
316    }
317
318    #[test]
319    fn classification() {
320        let a: SocketAddr = "1.2.3.4:1000".parse().unwrap();
321        let b: SocketAddr = "1.2.3.4:1001".parse().unwrap();
322        let cone = NatDiscovery {
323            reflexive: Some(a),
324            observed: vec![a, a],
325            symmetric: false,
326        };
327        assert_eq!(cone.nat_type(), NatType::Cone);
328        let sym = NatDiscovery {
329            reflexive: None,
330            observed: vec![a, b],
331            symmetric: true,
332        };
333        assert_eq!(sym.nat_type(), NatType::Symmetric);
334        assert_eq!(NatDiscovery::default().nat_type(), NatType::Unknown);
335    }
336}