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>
185 lines
6.6 KiB
Rust
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)
|
|
}
|
|
}
|
|
}
|