itsgoin/crates/core/src/stun.rs
Scott Reimers 800388cda4 ItsGoin v0.3.2 — Decentralized social media network
No central server, user-owned data, reverse-chronological feed.
Rust core + Tauri desktop + Android app + plain HTML/CSS/JS frontend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:23:09 -04:00

185 lines
6.6 KiB
Rust

//! Minimal raw STUN client for NAT type detection.
//! Sends STUN Binding Requests to two servers and compares mapped ports.
use std::net::SocketAddr;
use tokio::net::UdpSocket;
use tracing::{debug, warn};
use crate::types::{NatMapping, NatType};
const STUN_SERVERS: &[&str] = &[
"stun.l.google.com:19302",
"stun.cloudflare.com:3478",
];
const STUN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
/// STUN Binding Request (RFC 5389): 20 bytes
/// Type: 0x0001 (Binding Request), Length: 0, Magic: 0x2112A442, Transaction ID: 12 random bytes
fn build_binding_request() -> [u8; 20] {
let mut buf = [0u8; 20];
// Message type: Binding Request (0x0001)
buf[0] = 0x00;
buf[1] = 0x01;
// Message length: 0
buf[2] = 0x00;
buf[3] = 0x00;
// Magic cookie: 0x2112A442
buf[4] = 0x21;
buf[5] = 0x12;
buf[6] = 0xA4;
buf[7] = 0x42;
// Transaction ID: 12 random bytes
use rand::Rng;
let mut rng = rand::rng();
rng.fill(&mut buf[8..20]);
buf
}
/// Parse XOR-MAPPED-ADDRESS from a STUN Binding Response.
/// Returns the mapped SocketAddr or None if not found/parseable.
fn parse_xor_mapped_address(resp: &[u8], txn_id: &[u8; 12]) -> Option<SocketAddr> {
if resp.len() < 20 {
return None;
}
// Verify it's a Binding Response (0x0101)
if resp[0] != 0x01 || resp[1] != 0x01 {
return None;
}
let magic: [u8; 4] = [0x21, 0x12, 0xA4, 0x42];
// Walk attributes
let msg_len = u16::from_be_bytes([resp[2], resp[3]]) as usize;
let end = std::cmp::min(20 + msg_len, resp.len());
let mut pos = 20;
while pos + 4 <= end {
let attr_type = u16::from_be_bytes([resp[pos], resp[pos + 1]]);
let attr_len = u16::from_be_bytes([resp[pos + 2], resp[pos + 3]]) as usize;
pos += 4;
if pos + attr_len > end {
break;
}
// XOR-MAPPED-ADDRESS = 0x0020, MAPPED-ADDRESS = 0x0001
if attr_type == 0x0020 && attr_len >= 8 {
// byte 0: reserved, byte 1: family (0x01=IPv4, 0x02=IPv6)
let family = resp[pos + 1];
if family == 0x01 {
// IPv4
let xport = u16::from_be_bytes([resp[pos + 2], resp[pos + 3]])
^ u16::from_be_bytes([magic[0], magic[1]]);
let xip = [
resp[pos + 4] ^ magic[0],
resp[pos + 5] ^ magic[1],
resp[pos + 6] ^ magic[2],
resp[pos + 7] ^ magic[3],
];
let addr = SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::new(xip[0], xip[1], xip[2], xip[3])),
xport,
);
return Some(addr);
} else if family == 0x02 && attr_len >= 20 {
// IPv6: XOR with magic + txn_id
let xport = u16::from_be_bytes([resp[pos + 2], resp[pos + 3]])
^ u16::from_be_bytes([magic[0], magic[1]]);
let mut ip6 = [0u8; 16];
let xor_key: Vec<u8> = magic.iter().chain(txn_id.iter()).copied().collect();
for i in 0..16 {
ip6[i] = resp[pos + 4 + i] ^ xor_key[i];
}
let addr = SocketAddr::new(
std::net::IpAddr::V6(std::net::Ipv6Addr::from(ip6)),
xport,
);
return Some(addr);
}
}
// Pad to 4-byte boundary
pos += (attr_len + 3) & !3;
}
None
}
/// Query a single STUN server and return the mapped address.
async fn stun_query(sock: &UdpSocket, server: &str) -> Option<SocketAddr> {
use std::net::ToSocketAddrs;
let server_addr = match server.to_socket_addrs() {
Ok(mut addrs) => addrs.next()?,
Err(e) => {
debug!(server, error = %e, "STUN DNS resolution failed");
return None;
}
};
let request = build_binding_request();
let txn_id: [u8; 12] = request[8..20].try_into().unwrap();
if let Err(e) = sock.send_to(&request, server_addr).await {
debug!(server, error = %e, "STUN send failed");
return None;
}
let mut buf = [0u8; 256];
match tokio::time::timeout(STUN_TIMEOUT, sock.recv_from(&mut buf)).await {
Ok(Ok((len, _))) => parse_xor_mapped_address(&buf[..len], &txn_id),
Ok(Err(e)) => {
debug!(server, error = %e, "STUN recv failed");
None
}
Err(_) => {
debug!(server, "STUN query timed out (3s)");
None
}
}
}
/// Detect NAT type by comparing mapped addresses from two STUN servers.
/// Must be called with the local port we're interested in (for Public detection).
/// Also returns the NatMapping classification for the advanced NAT profile.
pub async fn detect_nat_type(local_port: u16) -> (NatType, NatMapping) {
let sock = match UdpSocket::bind("0.0.0.0:0").await {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "Failed to bind UDP socket for STUN");
return (NatType::Unknown, NatMapping::Unknown);
}
};
let local_addr = sock.local_addr().ok();
// Query both servers from the same socket
let result1 = stun_query(&sock, STUN_SERVERS[0]).await;
let result2 = stun_query(&sock, STUN_SERVERS[1]).await;
match (result1, result2) {
(Some(addr1), Some(addr2)) => {
debug!(
server1 = STUN_SERVERS[0], mapped1 = %addr1,
server2 = STUN_SERVERS[1], mapped2 = %addr2,
local_port,
"STUN results"
);
// If mapped port matches our local port, we might be public/no-NAT
if let Some(local) = local_addr {
if addr1.port() == local.port() && addr2.port() == local.port() {
return (NatType::Public, NatMapping::EndpointIndependent);
}
}
// Same mapped port from both = cone NAT (Easy / EIM)
// Different ports = symmetric NAT (Hard / EDM)
if addr1.port() == addr2.port() {
(NatType::Easy, NatMapping::EndpointIndependent)
} else {
(NatType::Hard, NatMapping::EndpointDependent)
}
}
(Some(addr), None) | (None, Some(addr)) => {
debug!(mapped = %addr, "Only one STUN server responded, assuming Easy");
(NatType::Easy, NatMapping::EndpointIndependent)
}
(None, None) => {
warn!("Both STUN servers unreachable, NAT type unknown");
(NatType::Unknown, NatMapping::Unknown)
}
}
}