From b9ff93b7740250b5d2df184508cab1eb45f30016 Mon Sep 17 00:00:00 2001 From: ghost Date: Mon, 29 Dec 2025 02:27:21 +0100 Subject: [PATCH] Add turn client for testing. More debugging output. --- src/auth.rs | 97 ++++++++++++ src/bin/turn_client.rs | 340 +++++++++++++++++++++++++++++++++++++++++ src/config.rs | 16 +- src/main.rs | 15 ++ 4 files changed, 460 insertions(+), 8 deletions(-) create mode 100644 src/bin/turn_client.rs diff --git a/src/auth.rs b/src/auth.rs index 476f267..1c4f745 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -93,6 +93,47 @@ pub struct AuthManager { settings: AuthSettings, } +fn build_signed_bytes_adjusted( + msg: &StunMessage, + mi_offset: usize, + mi_len: usize, +) -> Option> { + 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> { + 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 Clone for AuthManager { fn clone(&self) -> Self { Self { @@ -212,6 +253,62 @@ impl AuthManager { } // 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::()), + signed_len_hex.as_ref().map(|s| s.chars().take(160).collect::()) + ); + } + } + 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); diff --git a/src/bin/turn_client.rs b/src/bin/turn_client.rs new file mode 100644 index 0000000..97b7eb7 --- /dev/null +++ b/src/bin/turn_client.rs @@ -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 --port --user --pass

--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 { + let mut host: Option = None; + let mut port: Option = None; + let mut user: Option = None; + let mut pass: Option = None; + let mut realm: Option = 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::().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 { + 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> { + 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 { + 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 { + 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, 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) { + let len = (buf.len() - 20) as u16; + buf[2..4].copy_from_slice(&len.to_be_bytes()); +} + +fn append_attr(buf: &mut Vec, 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, 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::::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) { + // 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)>, +} + +fn parse_stun(buf: &[u8]) -> anyhow::Result { + 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, Option, Option) { + let mut code: Option = None; + let mut realm: Option = None; + let mut nonce: Option = 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) +} diff --git a/src/config.rs b/src/config.rs index 007636e..41b8c64 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, } -#[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, } -#[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, } -#[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, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] pub struct Config { /// Server options pub server: ServerOptions, diff --git a/src/main.rs b/src/main.rs index cff0c52..84b818a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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