Phase A of hard NAT traversal (PRD-hard-nat.md):
- PortAllocation enum: PortPreserving / Sequential{delta} / Random / Unknown
- detect_port_allocation(): sequential STUN probes from single socket,
analyzes port sequence for allocation pattern
- classify_port_allocation(): pure function with jitter tolerance,
wraparound handling, 60% threshold for noisy sequences
- predict_ports(): generates target port range from last_port + delta
- HardNatProbe signal message: carries port_sequence, allocation
pattern, external_ip for peer coordination
- Relay forwards HardNatProbe to call peer
- Netcheck gains port_allocation field + format_report display
588 tests pass (17 new), 0 regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1437 lines
52 KiB
Rust
1437 lines
52 KiB
Rust
//! 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<String>,
|
|
/// 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<SocketAddr, StunError> {
|
|
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<SocketAddr> = None;
|
|
let mut xor_mapped: Option<SocketAddr> = 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<SocketAddr, StunError> {
|
|
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<SocketAddr, StunError> {
|
|
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<SocketAddr, StunError> {
|
|
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<SocketAddr, StunError> {
|
|
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<SocketAddr> = 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<SocketAddr, StunError> {
|
|
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<StunError> = 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<crate::reflect::NatProbeResult> {
|
|
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:<panicked>".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<u16>,
|
|
/// External IP (consensus from probes, if available).
|
|
pub external_ip: Option<IpAddr>,
|
|
}
|
|
|
|
/// 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<SocketAddr> = 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<u16> = 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<i16> = 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<u16> {
|
|
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
|
|
);
|
|
}
|
|
}
|
|
}
|