Add turn client for testing. More debugging output.

This commit is contained in:
ghost 2025-12-29 02:27:21 +01:00
parent abf9b87659
commit b9ff93b774
4 changed files with 460 additions and 8 deletions

View File

@ -93,6 +93,47 @@ pub struct AuthManager<S: CredentialStore + Clone> {
settings: AuthSettings,
}
fn build_signed_bytes_adjusted(
msg: &StunMessage,
mi_offset: usize,
mi_len: usize,
) -> Option<Vec<u8>> {
if mi_len != 20 {
return None;
}
let mi_end = mi_offset.checked_add(4 + mi_len)?;
if mi_end > msg.raw.len() {
return None;
}
let mut signed = msg.raw[..mi_end].to_vec();
let len = (mi_end - 20) as u16;
signed[2..4].copy_from_slice(&len.to_be_bytes());
let value_start = mi_offset + 4;
signed[value_start..value_start + mi_len].fill(0);
Some(signed)
}
fn build_signed_bytes_len_preserved(
msg: &StunMessage,
mi_offset: usize,
mi_len: usize,
) -> Option<Vec<u8>> {
if mi_len != 20 {
return None;
}
let mi_end = mi_offset.checked_add(4 + mi_len)?;
if mi_end > msg.raw.len() {
return None;
}
let mut signed = msg.raw[..mi_end].to_vec();
let value_start = mi_offset + 4;
signed[value_start..value_start + mi_len].fill(0);
Some(signed)
}
impl<S: CredentialStore + Clone> Clone for AuthManager<S> {
fn clone(&self) -> Self {
Self {
@ -212,6 +253,62 @@ impl<S: CredentialStore + Clone> AuthManager<S> {
}
// No acceptance without MI validation. Emit detailed diagnostics.
// Keep logs compact by default to avoid journald truncation.
// Set NIOM_TURN_DEBUG_AUTH_HEX=1 for a summary, and NIOM_TURN_DEBUG_AUTH_HEX_FULL=1 for full raw/signed hex.
if std::env::var_os("NIOM_TURN_DEBUG_AUTH_HEX").is_some() {
let mi = find_message_integrity(msg);
let mi_end = mi.map(|a| a.offset + 4 + a.value.len()).unwrap_or(0);
let mut attrs = Vec::new();
for a in &msg.attributes {
attrs.push(format!(
"t=0x{:04x} len={} off={} v={}",
a.typ,
a.value.len(),
a.offset,
hex::encode(&a.value)
));
}
let signed_adj_hex = mi
.and_then(|a| build_signed_bytes_adjusted(msg, a.offset, a.value.len()))
.map(hex::encode);
let signed_len_hex = mi
.and_then(|a| build_signed_bytes_len_preserved(msg, a.offset, a.value.len()))
.map(hex::encode);
if std::env::var_os("NIOM_TURN_DEBUG_AUTH_HEX_FULL").is_some() {
warn!(
"auth debug dump FULL peer={} msg_type=0x{:04x} raw_len={} raw={} mi_end={} attrs=[{}] signed_adj={:?} signed_len_preserved={:?}",
peer,
msg.header.msg_type,
msg.raw.len(),
hex::encode(&msg.raw),
mi_end,
attrs.join(" | "),
signed_adj_hex,
signed_len_hex
);
} else {
let mi_hex = mi.map(|a| hex::encode(&a.value));
warn!(
"auth debug dump peer={} msg_type=0x{:04x} raw_len={} mi_end={} mi={:?} attrs=[{}]",
peer,
msg.header.msg_type,
msg.raw.len(),
mi_end,
mi_hex,
attrs.join(" | ")
);
warn!(
"auth debug signed (truncated) peer={} signed_adj_prefix={:?} signed_len_preserved_prefix={:?}",
peer,
signed_adj_hex.as_ref().map(|s| s.chars().take(160).collect::<String>()),
signed_len_hex.as_ref().map(|s| s.chars().take(160).collect::<String>())
);
}
}
let mi_attr = find_message_integrity(msg).map(|a| hex::encode(&a.value));
let mi_long_adj = compute_message_integrity_adjusted(msg, &key).map(hex::encode);
let mi_long_len = compute_mi_len_preserved(msg, &key).map(hex::encode);

340
src/bin/turn_client.rs Normal file
View File

@ -0,0 +1,340 @@
use anyhow::{anyhow, bail, Context};
use crc32fast::Hasher;
use hmac::{Hmac, Mac};
use sha1::Sha1;
use std::net::{SocketAddr, ToSocketAddrs};
use std::time::Duration;
use tokio::net::UdpSocket;
use uuid::Uuid;
const MAGIC_COOKIE: u32 = 0x2112A442;
// STUN/TURN attribute types (RFC 5389 / RFC 5766)
const ATTR_USERNAME: u16 = 0x0006;
const ATTR_MESSAGE_INTEGRITY: u16 = 0x0008;
const ATTR_ERROR_CODE: u16 = 0x0009;
const ATTR_REALM: u16 = 0x0014;
const ATTR_NONCE: u16 = 0x0015;
const ATTR_REQUESTED_TRANSPORT: u16 = 0x0019;
const ATTR_FINGERPRINT: u16 = 0x8028;
#[derive(Debug, Clone)]
struct Opt {
host: String,
port: u16,
user: String,
pass: String,
realm: String,
}
fn usage() -> &'static str {
"turn_client --host <host> --port <port> --user <u> --pass <p> --realm <realm>\n\nExample:\n cargo run --bin turn_client -- --host ghostnet.selfhost.eu --port 3478 --user testuser --pass secretpassword --realm ghostnet.selfhost.eu\n"
}
fn parse_args() -> anyhow::Result<Opt> {
let mut host: Option<String> = None;
let mut port: Option<u16> = None;
let mut user: Option<String> = None;
let mut pass: Option<String> = None;
let mut realm: Option<String> = None;
let mut it = std::env::args().skip(1);
while let Some(a) = it.next() {
match a.as_str() {
"--host" => host = Some(it.next().ok_or_else(|| anyhow!("missing value for --host"))?),
"--port" => {
let p = it.next().ok_or_else(|| anyhow!("missing value for --port"))?;
port = Some(p.parse::<u16>().context("invalid --port")?);
}
"--user" => user = Some(it.next().ok_or_else(|| anyhow!("missing value for --user"))?),
"--pass" => pass = Some(it.next().ok_or_else(|| anyhow!("missing value for --pass"))?),
"--realm" => realm = Some(it.next().ok_or_else(|| anyhow!("missing value for --realm"))?),
"-h" | "--help" => bail!(usage()),
other => bail!("unknown arg: {other}\n\n{}", usage()),
}
}
Ok(Opt {
host: host.ok_or_else(|| anyhow!("--host required"))?,
port: port.unwrap_or(3478),
user: user.ok_or_else(|| anyhow!("--user required"))?,
pass: pass.ok_or_else(|| anyhow!("--pass required"))?,
realm: realm.ok_or_else(|| anyhow!("--realm required"))?,
})
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let opt = match parse_args() {
Ok(v) => v,
Err(e) => {
eprintln!("{e}\n");
return Ok(());
}
};
let target = resolve_target(&opt.host, opt.port)?;
println!("target={target}");
let sock = UdpSocket::bind("0.0.0.0:0").await?;
sock.connect(target).await?;
// 1) Allocate without auth to get 401 + realm/nonce
let trans_id_1 = random_trans_id();
let req1 = build_allocate_request_unauth(trans_id_1);
println!("req1 raw={}", hex::encode(&req1));
let resp1 = send_and_recv(&sock, &req1).await?;
let parsed1 = parse_stun(&resp1)?;
println!("resp1 type=0x{:04x} len={} raw={}", parsed1.msg_type, parsed1.length, hex::encode(&resp1));
let (code1, realm1, nonce1) = extract_error_realm_nonce(&parsed1);
println!("resp1 error={code1:?} realm={realm1:?} nonce_len={}", nonce1.as_deref().map(|n| n.len()).unwrap_or(0));
let nonce = nonce1.ok_or_else(|| anyhow!("no NONCE in first response"))?;
let realm = realm1.unwrap_or_else(|| opt.realm.clone());
// 2) Allocate with long-term auth (USERNAME/REALM/NONCE + MI + FINGERPRINT)
let trans_id_2 = random_trans_id();
let key = long_term_key_md5(&opt.user, &realm, &opt.pass);
println!("key_md5={}", hex::encode(&key));
let req2 = build_allocate_request_auth(trans_id_2, &opt.user, &realm, &nonce, &key, true);
println!("req2 raw={}", hex::encode(&req2));
let resp2 = send_and_recv(&sock, &req2).await?;
let parsed2 = parse_stun(&resp2)?;
println!("resp2 type=0x{:04x} len={} raw={}", parsed2.msg_type, parsed2.length, hex::encode(&resp2));
let (code2, _realm2, _nonce2) = extract_error_realm_nonce(&parsed2);
if let Some(code) = code2 {
println!("resp2 error={code}");
} else {
println!("resp2 looks non-error (success?)");
}
Ok(())
}
fn resolve_target(host: &str, port: u16) -> anyhow::Result<SocketAddr> {
let mut addrs = (host, port)
.to_socket_addrs()
.with_context(|| format!("DNS resolve failed for {host}:{port}"))?;
addrs
.next()
.ok_or_else(|| anyhow!("no socket addresses for {host}:{port}"))
}
fn random_trans_id() -> [u8; 12] {
let u = Uuid::new_v4();
let b = u.as_bytes();
let mut out = [0u8; 12];
out.copy_from_slice(&b[0..12]);
out
}
async fn send_and_recv(sock: &UdpSocket, req: &[u8]) -> anyhow::Result<Vec<u8>> {
sock.send(req).await?;
let mut buf = [0u8; 2048];
let n = tokio::time::timeout(Duration::from_secs(2), sock.recv(&mut buf)).await??;
Ok(buf[..n].to_vec())
}
fn build_allocate_request_unauth(trans_id: [u8; 12]) -> Vec<u8> {
let msg_type: u16 = 0x0003; // Allocate request
let mut buf = Vec::with_capacity(256);
write_header(&mut buf, msg_type, trans_id);
// REQUESTED-TRANSPORT (UDP = 17)
append_attr(&mut buf, ATTR_REQUESTED_TRANSPORT, &[17, 0, 0, 0]);
// No USERNAME/REALM/NONCE/MI
finalize_len(&mut buf);
buf
}
fn build_allocate_request_auth(
trans_id: [u8; 12],
user: &str,
realm: &str,
nonce: &str,
key: &[u8],
fingerprint: bool,
) -> Vec<u8> {
let msg_type: u16 = 0x0003; // Allocate request
let mut buf = Vec::with_capacity(512);
write_header(&mut buf, msg_type, trans_id);
append_attr(&mut buf, ATTR_REQUESTED_TRANSPORT, &[17, 0, 0, 0]);
append_attr(&mut buf, ATTR_USERNAME, user.as_bytes());
append_attr(&mut buf, ATTR_REALM, realm.as_bytes());
append_attr(&mut buf, ATTR_NONCE, nonce.as_bytes());
// MESSAGE-INTEGRITY, then optionally FINGERPRINT after it.
append_message_integrity_rfc(&mut buf, key);
if fingerprint {
append_fingerprint(&mut buf);
} else {
finalize_len(&mut buf);
}
buf
}
fn write_header(buf: &mut Vec<u8>, msg_type: u16, trans_id: [u8; 12]) {
buf.extend_from_slice(&msg_type.to_be_bytes());
buf.extend_from_slice(&0u16.to_be_bytes()); // length placeholder
buf.extend_from_slice(&MAGIC_COOKIE.to_be_bytes());
buf.extend_from_slice(&trans_id);
}
fn finalize_len(buf: &mut Vec<u8>) {
let len = (buf.len() - 20) as u16;
buf[2..4].copy_from_slice(&len.to_be_bytes());
}
fn append_attr(buf: &mut Vec<u8>, typ: u16, val: &[u8]) {
buf.extend_from_slice(&typ.to_be_bytes());
buf.extend_from_slice(&(val.len() as u16).to_be_bytes());
buf.extend_from_slice(val);
while (buf.len() % 4) != 0 {
buf.push(0);
}
}
fn long_term_key_md5(user: &str, realm: &str, pass: &str) -> [u8; 16] {
let s = format!("{user}:{realm}:{pass}");
let d = md5::compute(s.as_bytes());
d.0
}
/// RFC 5389 style MI computation:
/// - HMAC is computed over the message up to (and including) the MI attribute
/// - In the bytes being MAC'ed, the STUN header length is set to the length up to MI
/// - The MI value bytes are treated as zero for the MAC
fn append_message_integrity_rfc(buf: &mut Vec<u8>, key: &[u8]) {
const HMAC_LEN: usize = 20;
// Add MI with zero placeholder
buf.extend_from_slice(&ATTR_MESSAGE_INTEGRITY.to_be_bytes());
buf.extend_from_slice(&(HMAC_LEN as u16).to_be_bytes());
let mi_pos = buf.len();
buf.extend_from_slice(&[0u8; HMAC_LEN]);
while (buf.len() % 4) != 0 {
buf.push(0);
}
let mi_end = buf.len();
// Compute HMAC over header..mi_end, but with adjusted length and MI bytes set to 0.
let mut signed = buf[..mi_end].to_vec();
let adjusted_len = (mi_end - 20) as u16;
signed[2..4].copy_from_slice(&adjusted_len.to_be_bytes());
for b in &mut signed[mi_pos..mi_pos + HMAC_LEN] {
*b = 0;
}
let mut mac = Hmac::<Sha1>::new_from_slice(key).expect("HMAC key");
mac.update(&signed);
let h = mac.finalize().into_bytes();
buf[mi_pos..mi_pos + HMAC_LEN].copy_from_slice(&h[..HMAC_LEN]);
// Important: leave final length setting to caller (because they may append FINGERPRINT).
}
fn append_fingerprint(buf: &mut Vec<u8>) {
// RFC 5389 section 15.7 (matches the server's behavior):
// - FINGERPRINT must be last.
// - Header length used for CRC is the final message length (including fingerprint).
// - CRC input excludes the fingerprint attribute itself.
let fp_attr_offset = buf.len();
buf.extend_from_slice(&ATTR_FINGERPRINT.to_be_bytes());
buf.extend_from_slice(&(4u16).to_be_bytes());
let fp_value_pos = buf.len();
buf.extend_from_slice(&[0u8; 4]);
// Update header length to include fingerprint.
finalize_len(buf);
let mut hasher = Hasher::new();
hasher.update(&buf[..fp_attr_offset]);
let crc = hasher.finalize() ^ 0x5354554e;
buf[fp_value_pos..fp_value_pos + 4].copy_from_slice(&crc.to_be_bytes());
}
#[derive(Debug)]
struct Parsed {
msg_type: u16,
length: u16,
trans_id: [u8; 12],
attrs: Vec<(u16, Vec<u8>)>,
}
fn parse_stun(buf: &[u8]) -> anyhow::Result<Parsed> {
if buf.len() < 20 {
bail!("too short for STUN header");
}
let msg_type = u16::from_be_bytes([buf[0], buf[1]]);
let length = u16::from_be_bytes([buf[2], buf[3]]);
let cookie = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]);
if cookie != MAGIC_COOKIE {
bail!("invalid magic cookie: 0x{cookie:08x}");
}
let mut trans_id = [0u8; 12];
trans_id.copy_from_slice(&buf[8..20]);
let total = 20usize + (length as usize);
if buf.len() < total {
bail!("buffer shorter than STUN length: buf={} total={total}", buf.len());
}
let mut off = 20usize;
let mut attrs = Vec::new();
while off + 4 <= total {
let typ = u16::from_be_bytes([buf[off], buf[off + 1]]);
let l = u16::from_be_bytes([buf[off + 2], buf[off + 3]]) as usize;
off += 4;
if off + l > total {
bail!("attr overflow typ=0x{typ:04x} len={l}");
}
let val = buf[off..off + l].to_vec();
attrs.push((typ, val));
off += l;
off += (4 - (l % 4)) % 4;
}
Ok(Parsed {
msg_type,
length,
trans_id,
attrs,
})
}
fn extract_error_realm_nonce(p: &Parsed) -> (Option<u16>, Option<String>, Option<String>) {
let mut code: Option<u16> = None;
let mut realm: Option<String> = None;
let mut nonce: Option<String> = None;
for (typ, val) in &p.attrs {
match *typ {
ATTR_ERROR_CODE => {
if val.len() >= 4 {
let class = val[2] as u16;
let number = val[3] as u16;
code = Some(class * 100 + number);
}
}
ATTR_REALM => {
if let Ok(s) = std::str::from_utf8(val) {
realm = Some(s.to_string());
}
}
ATTR_NONCE => {
if let Ok(s) = std::str::from_utf8(val) {
nonce = Some(s.to_string());
}
}
_ => {}
}
}
(code, realm, nonce)
}

View File

@ -1,6 +1,6 @@
//! Configuration loader for server bind addresses, TLS artifacts, and seed credentials.
//! Backlog: hot-reload support, secret injection, and environment overrides per deployment.
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::path::Path;
fn default_realm() -> String {
@ -31,13 +31,13 @@ fn default_enable_tls() -> bool {
true
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct CredentialEntry {
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AuthOptions {
/// STUN/TURN realm advertised to clients when issuing challenges.
#[serde(default = "default_realm")]
@ -72,7 +72,7 @@ impl Default for AuthOptions {
}
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ServerOptions {
/// Listen address (legacy/default), e.g. "0.0.0.0:3478".
/// If `udp_bind` / `tcp_bind` are not set, this value is used.
@ -99,7 +99,7 @@ pub struct ServerOptions {
pub tls_key: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Default)]
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct RelayOptions {
/// Optional UDP relay port range. If set, allocations bind within this range.
/// If omitted, OS chooses an ephemeral port.
@ -119,7 +119,7 @@ pub struct RelayOptions {
pub advertised_ip: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Default)]
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct LoggingOptions {
/// Default tracing directive (overridable via RUST_LOG).
/// Example: "warn,niom_turn=info".
@ -127,7 +127,7 @@ pub struct LoggingOptions {
pub default_directive: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Default)]
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct LimitsOptions {
/// Max concurrent allocations per source IP (across different source ports).
/// If omitted, unlimited.
@ -159,7 +159,7 @@ pub struct LimitsOptions {
pub binding_burst: Option<u32>,
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Config {
/// Server options
pub server: ServerOptions,

View File

@ -91,6 +91,21 @@ async fn main() -> anyhow::Result<()> {
);
info!("logging.default_directive={}", log_directive);
// Debug-only: dump the fully loaded config (including secrets) to prove what we are using.
// Enable explicitly: NIOM_TURN_DEBUG_CONFIG=1
// Optional pretty JSON: NIOM_TURN_DEBUG_CONFIG_JSON=1
if std::env::var_os("NIOM_TURN_DEBUG_CONFIG").is_some() {
warn!("DEBUG CONFIG DUMP ENABLED (includes secrets). Disable after verification.");
if std::env::var_os("NIOM_TURN_DEBUG_CONFIG_JSON").is_some() {
match serde_json::to_string_pretty(&cfg) {
Ok(s) => warn!("config dump (json) source={}\n{}", cfg_source, s),
Err(e) => warn!("config dump (json) failed: {:?}", e),
}
} else {
warn!("config dump (debug) source={} cfg={:?}", cfg_source, cfg);
}
}
let udp_bind = cfg
.server
.udp_bind