mod message;
pub use message::{decode_binding_response, encode_binding_request, MAGIC_COOKIE};
use std::io;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::UdpSocket;
use tokio::time::timeout;
#[derive(Debug, thiserror::Error)]
pub enum StunError {
#[error("STUN message too short ({got} bytes; need at least 20)")]
TooShort { got: usize },
#[error("STUN magic cookie mismatch (got 0x{got:08x}; expected 0x{expected:08x})")]
MagicCookieMismatch { got: u32, expected: u32 },
#[error("STUN transaction id mismatch (request != response)")]
TransactionIdMismatch,
#[error("STUN message type 0x{0:04x} is not a Binding Response")]
NotBindingResponse(u16),
#[error("STUN response carried no MAPPED-ADDRESS or XOR-MAPPED-ADDRESS")]
NoMappedAddress,
#[error("STUN attribute family 0x{0:02x} unrecognised (expected 0x01 IPv4 or 0x02 IPv6)")]
UnknownAddressFamily(u8),
#[error("STUN attribute body too short ({got} bytes; need {need})")]
AttributeTruncated { got: usize, need: usize },
#[error("STUN probe timed out after {budget_ms} ms across {attempts} attempts")]
ProbeTimeout { attempts: u32, budget_ms: u64 },
#[error("STUN socket I/O error: {0}")]
Io(#[from] io::Error),
}
pub struct StunClient {
socket: Arc<UdpSocket>,
server: SocketAddr,
attempt_timeout: Duration,
total_budget: Duration,
}
impl StunClient {
pub fn new(socket: Arc<UdpSocket>, server: SocketAddr) -> Self {
Self {
socket,
server,
attempt_timeout: Duration::from_millis(500),
total_budget: Duration::from_millis(1_500),
}
}
pub fn with_attempt_timeout(mut self, t: Duration) -> Self {
self.attempt_timeout = t;
self
}
pub fn with_total_budget(mut self, t: Duration) -> Self {
self.total_budget = t;
self
}
pub async fn discover(&self) -> Result<SocketAddr, StunError> {
let started = std::time::Instant::now();
let mut attempts: u32 = 0;
let mut buf = [0u8; 1500];
while started.elapsed() < self.total_budget {
attempts += 1;
let (request, txn_id) = encode_binding_request();
self.socket.send_to(&request, self.server).await?;
let remaining = self.total_budget.saturating_sub(started.elapsed());
let wait = self.attempt_timeout.min(remaining);
if wait.is_zero() {
break;
}
match timeout(wait, self.socket.recv_from(&mut buf)).await {
Ok(Ok((n, peer))) => {
if peer != self.server {
tracing::trace!(
"STUN: ignoring packet from non-server {} (expected {})",
peer,
self.server
);
continue;
}
match decode_binding_response(&buf[..n], &txn_id) {
Ok(addr) => return Ok(addr),
Err(StunError::TransactionIdMismatch) => {
tracing::trace!("STUN: ignored stale response (txn id mismatch)");
continue;
}
Err(e) => return Err(e),
}
}
Ok(Err(e)) => return Err(StunError::Io(e)),
Err(_) => {
tracing::debug!(
"STUN: attempt {} timed out after {:?}, retrying",
attempts,
wait
);
}
}
}
Err(StunError::ProbeTimeout {
attempts,
budget_ms: self.total_budget.as_millis() as u64,
})
}
}