netget/client/turn/mod.rs
1//! TURN client implementation
2pub mod actions;
3
4pub use actions::TurnClientProtocol;
5
6use anyhow::{Context, Result};
7use std::collections::HashMap;
8use std::net::SocketAddr;
9use std::sync::Arc;
10use tokio::net::UdpSocket;
11use tokio::sync::{mpsc, Mutex};
12use tracing::{debug, error, info, trace, warn};
13
14use crate::client::llm_budget::call_llm_for_client;
15use crate::client::turn::actions::{
16 TURN_CLIENT_ALLOCATED_EVENT, TURN_CLIENT_CONNECTED_EVENT, TURN_CLIENT_DATA_RECEIVED_EVENT,
17 TURN_CLIENT_PERMISSION_CREATED_EVENT, TURN_CLIENT_REFRESHED_EVENT,
18};
19use crate::llm::ollama_client::OllamaClient;
20use crate::llm::ClientLlmResult;
21use crate::logging::emit::Log;
22use crate::protocol::Event;
23use crate::state::app_state::AppState;
24use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
25use crate::state::{AccessLogOwner, ClientId, ClientStatus};
26
27/// Connection state for LLM processing
28#[derive(Debug, Clone, PartialEq)]
29enum ConnectionState {
30 Idle,
31 Processing,
32 Accumulating,
33}
34
35/// Per-client data for LLM handling
36struct ClientData {
37 state: ConnectionState,
38 queued_events: Vec<Event>,
39 memory: String,
40 relay_address: Option<SocketAddr>,
41 /// Transaction ID (hex) -> the peer a CreatePermission request named.
42 ///
43 /// A CreatePermission Success Response carries no XOR-PEER-ADDRESS — RFC 8656
44 /// section 9.4 says it is empty — so the only way to know which peer was
45 /// permitted is to remember what we asked for. Without this the
46 /// `turn_permission_created` event reported `"peer_address": "unknown"` on a
47 /// field its own declaration marks `required: true`, i.e. a fabricated
48 /// constant where the model expected a fact.
49 ///
50 /// Bounded at `MAX_PENDING_PERMISSIONS` so a model issuing create_permission
51 /// in a loop cannot grow it without limit; a request evicted early simply
52 /// reports the peer as unknown again, which is the old behaviour and no worse.
53 pending_permissions: HashMap<String, SocketAddr>,
54}
55
56/// Cap on outstanding CreatePermission requests remembered for correlation.
57const MAX_PENDING_PERMISSIONS: usize = 256;
58
59/// TURN client that connects to a remote TURN server
60pub struct TurnClient;
61
62impl TurnClient {
63 /// Connect to a TURN server with integrated LLM actions
64 pub async fn connect_with_llm_actions(
65 remote_addr: String,
66 llm_client: OllamaClient,
67 app_state: Arc<AppState>,
68 status_tx: mpsc::UnboundedSender<String>,
69 client_id: ClientId,
70 ) -> Result<SocketAddr> {
71 // Parse remote address
72 let remote_sock_addr: SocketAddr = remote_addr
73 .parse()
74 .context(format!("Invalid TURN server address: {}", remote_addr))?;
75
76 // Create UDP socket (TURN uses UDP transport)
77 let socket = UdpSocket::bind("0.0.0.0:0")
78 .await
79 .context("Failed to bind UDP socket")?;
80
81 let local_addr = socket.local_addr()?;
82
83 info!(
84 "TURN client {} bound to {} (server: {})",
85 client_id, local_addr, remote_sock_addr
86 );
87
88 // Update client state
89 app_state
90 .update_client_status(client_id, ClientStatus::Connected)
91 .await;
92 Log::new(Some(&status_tx)).info(format!(
93 "TURN client {} connected to {}",
94 client_id, remote_sock_addr
95 ));
96 let _ = status_tx.send("__UPDATE_UI__".to_string());
97
98 // Send initial connected event to LLM
99 let protocol = Arc::new(crate::client::turn::actions::TurnClientProtocol::new());
100 let event = Event::new(
101 &TURN_CLIENT_CONNECTED_EVENT,
102 serde_json::json!({
103 "remote_addr": remote_sock_addr.to_string(),
104 }),
105 );
106
107 let socket_arc = Arc::new(socket);
108
109 // Initialize client data
110 let client_data = Arc::new(Mutex::new(ClientData {
111 state: ConnectionState::Idle,
112 queued_events: Vec::new(),
113 memory: String::new(),
114 relay_address: None,
115 pending_permissions: HashMap::new(),
116 }));
117
118 // Command channel for injected actions (the dashboard's [ send ]).
119 // Registered BEFORE the connected-event LLM call: a manual `*` routing rule can
120 // park that call for minutes, and the operator must be able to reach the client
121 // while it waits. It gets its own task rather than a `select!` arm in the read
122 // loop, so an injected Allocate/Send does not queue behind an in-flight LLM call;
123 // the socket is an `Arc<UdpSocket>` and `send_to` takes `&self`, so both tasks can
124 // write to it.
125 let command_rx =
126 crate::client::command_support::register_command_channel(&app_state, client_id).await;
127 let cmd_task = tokio::spawn(Self::command_loop(
128 command_rx,
129 socket_arc.clone(),
130 remote_sock_addr,
131 protocol.clone(),
132 client_data.clone(),
133 client_id,
134 app_state.clone(),
135 status_tx.clone(),
136 ));
137 app_state.register_client_task(client_id, cmd_task).await;
138
139 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
140 match call_llm_for_client(
141 &llm_client,
142 &app_state,
143 client_id.to_string(),
144 &instruction,
145 "",
146 Some(&event),
147 protocol.as_ref(),
148 &status_tx,
149 )
150 .await
151 {
152 Ok(result) => {
153 debug!(
154 "TURN client {} initial LLM call returned {} actions",
155 client_id,
156 result.actions.len()
157 );
158
159 // Run them. They were counted, logged and dropped, so a client told
160 // to "connect and allocate a relay" connected and then sat there --
161 // nothing ever reached the socket, and the server saw no request at
162 // all. They go through `handle_action_result`, the same function the
163 // read loop and the injected-command path use, so the wire encoding
164 // exists once.
165 use crate::llm::actions::client_trait::Client;
166 for action in result.actions {
167 let executed = match protocol.as_ref().execute_action(action) {
168 Ok(executed) => executed,
169 Err(e) => {
170 error!("TURN client {} rejected action: {}", client_id, e);
171 continue;
172 }
173 };
174 if let Err(e) = Self::handle_action_result(
175 executed,
176 &socket_arc,
177 remote_sock_addr,
178 &client_data,
179 &status_tx,
180 client_id,
181 )
182 .await
183 {
184 error!("TURN client {} initial action failed: {}", client_id, e);
185 }
186 }
187 }
188 Err(e) => {
189 error!("TURN client {} initial LLM call failed: {}", client_id, e);
190 }
191 }
192 }
193
194 // Spawn read loop for receiving TURN responses
195 let socket_clone = socket_arc.clone();
196 let llm_clone = llm_client.clone();
197 let app_state_clone = app_state.clone();
198 let status_clone = status_tx.clone();
199 let client_data_clone = client_data.clone();
200 let protocol_clone = protocol.clone();
201
202 // Registered with AppState so stop_client can abort this task —
203 // dropping a JoinHandle only detaches it in Tokio.
204 let task_registrar = app_state.clone();
205 let task_handle = tokio::spawn(async move {
206 let mut buffer = vec![0u8; 2048]; // TURN messages typically < 2KB
207
208 loop {
209 match socket_clone.recv_from(&mut buffer).await {
210 Ok((n, peer_addr)) => {
211 let data = buffer[..n].to_vec();
212 trace!(
213 "TURN client {} received {} bytes from {}",
214 client_id,
215 n,
216 peer_addr
217 );
218
219 // Parse TURN/STUN message
220 let (transaction_id, message_type, _is_valid) =
221 Self::parse_turn_header(&data);
222
223 let transaction_id_hex = transaction_id
224 .map(|tid| hex::encode(tid))
225 .unwrap_or_default();
226
227 debug!(
228 "TURN client {} received {} (transaction: {})",
229 client_id, message_type, transaction_id_hex
230 );
231
232 // Determine event based on message type
233 let event = match message_type.as_str() {
234 "AllocateResponse" => {
235 // Extract relay address from XOR-RELAYED-ADDRESS attribute
236 if let Some(relay_addr) = Self::extract_xor_relayed_address(&data) {
237 let lifetime = Self::extract_lifetime(&data).unwrap_or(600);
238
239 // Store relay address
240 client_data_clone.lock().await.relay_address = Some(relay_addr);
241
242 // Also on the client record, so the relay this
243 // allocation actually got is visible to the dashboard,
244 // to MCP and to anything reading `protocol_data`. It
245 // used to live only in this task's private `ClientData`,
246 // where nothing outside the read loop could see it —
247 // which is also why no test could observe whether the
248 // response had been parsed at all.
249 app_state_clone
250 .with_client_mut(client_id, |client| {
251 client.set_protocol_field(
252 "relay_address".to_string(),
253 serde_json::json!(relay_addr.to_string()),
254 );
255 })
256 .await;
257
258 Some(Event::new(
259 &TURN_CLIENT_ALLOCATED_EVENT,
260 serde_json::json!({
261 "relay_address": relay_addr.to_string(),
262 "lifetime_seconds": lifetime,
263 "transaction_id": transaction_id_hex,
264 }),
265 ))
266 } else {
267 None
268 }
269 }
270 "RefreshResponse" => {
271 let lifetime = Self::extract_lifetime(&data).unwrap_or(600);
272 Some(Event::new(
273 &TURN_CLIENT_REFRESHED_EVENT,
274 serde_json::json!({
275 "lifetime_seconds": lifetime,
276 }),
277 ))
278 }
279 "CreatePermissionResponse" => {
280 // RFC 8656 section 9.4: the Success Response is empty, so
281 // the peer is recovered from the transaction ID we recorded
282 // when the request went out. This used to report the literal
283 // string "unknown" for a field declared `required: true`.
284 let peer_address = client_data_clone
285 .lock()
286 .await
287 .pending_permissions
288 .remove(&transaction_id_hex);
289
290 match peer_address {
291 Some(peer) => Some(Event::new(
292 &TURN_CLIENT_PERMISSION_CREATED_EVENT,
293 serde_json::json!({
294 "peer_address": peer.to_string(),
295 "transaction_id": transaction_id_hex,
296 }),
297 )),
298 None => {
299 // Nothing we sent matches this transaction ID, so
300 // either it is a stray or a forgery. Raising the
301 // event with a made-up peer would tell the model a
302 // permission exists that it never asked for.
303 warn!(
304 "TURN client {} ignoring CreatePermission response \
305 for unknown transaction {}",
306 client_id, transaction_id_hex
307 );
308 None
309 }
310 }
311 }
312 "DataIndication" => {
313 // Extract peer address and data from DATA and XOR-PEER-ADDRESS
314 if let (Some(peer_addr), Some(relay_data)) = (
315 Self::extract_xor_peer_address(&data),
316 Self::extract_data_attribute(&data),
317 ) {
318 Some(Event::new(
319 &TURN_CLIENT_DATA_RECEIVED_EVENT,
320 serde_json::json!({
321 "peer_address": peer_addr.to_string(),
322 "data_hex": hex::encode(&relay_data),
323 "data_length": relay_data.len(),
324 }),
325 ))
326 } else {
327 None
328 }
329 }
330 "AllocateError" | "RefreshError" | "CreatePermissionError" => {
331 let error_code = Self::extract_error_code(&data).unwrap_or(400);
332 Log::new(Some(&status_clone)).error(format!(
333 "TURN client {} received error: {} (code: {})",
334 client_id, message_type, error_code
335 ));
336 None
337 }
338 _ => {
339 debug!(
340 "TURN client {} ignoring message type: {}",
341 client_id, message_type
342 );
343 None
344 }
345 };
346
347 if let Some(event) = event {
348 // Handle event with LLM
349 let mut client_data_lock = client_data_clone.lock().await;
350
351 match client_data_lock.state {
352 ConnectionState::Idle => {
353 // Process immediately
354 client_data_lock.state = ConnectionState::Processing;
355 drop(client_data_lock);
356
357 // Call LLM
358 if let Some(instruction) =
359 app_state_clone.get_instruction_for_client(client_id).await
360 {
361 // Copy the memory out and drop the guard BEFORE the
362 // call. `&client_data_clone.lock().await.memory` as a
363 // match scrutinee kept the guard alive for the whole
364 // round-trip *and* the whole arm, so the re-lock in
365 // the Ok arm below deadlocked this read loop against
366 // itself on a non-reentrant tokio Mutex. It never
367 // fired only because `memory_updates` is currently
368 // hardcoded `None` in `action_helper` — a latent hang
369 // waiting on an unrelated feature being finished.
370 let memory = client_data_clone.lock().await.memory.clone();
371 match call_llm_for_client(
372 &llm_clone,
373 &app_state_clone,
374 client_id.to_string(),
375 &instruction,
376 &memory,
377 Some(&event),
378 protocol_clone.as_ref(),
379 &status_clone,
380 )
381 .await
382 {
383 Ok(ClientLlmResult {
384 actions,
385 memory_updates,
386 }) => {
387 // Update memory
388 if let Some(mem) = memory_updates {
389 client_data_clone.lock().await.memory = mem;
390 }
391
392 // Execute actions
393 for action in actions {
394 use crate::llm::actions::client_trait::Client;
395 match protocol_clone
396 .as_ref()
397 .execute_action(action)
398 {
399 Ok(action_result) => {
400 match Self::handle_action_result(
401 action_result,
402 &socket_clone,
403 remote_sock_addr,
404 &client_data_clone,
405 &status_clone,
406 client_id,
407 )
408 .await
409 {
410 Ok(outcome) => debug!(
411 "TURN client {} action outcome: {:?}",
412 client_id, outcome
413 ),
414 Err(e) => error!("TURN client {} action execution failed: {}", client_id, e),
415 }
416 }
417 Err(e) => {
418 error!("TURN client {} action parsing failed: {}", client_id, e);
419 }
420 }
421 }
422 }
423 Err(e) => {
424 error!(
425 "LLM error for TURN client {}: {}",
426 client_id, e
427 );
428 }
429 }
430 }
431
432 // Process queued events if any
433 let mut client_data_lock = client_data_clone.lock().await;
434 if !client_data_lock.queued_events.is_empty() {
435 client_data_lock.queued_events.clear();
436 }
437 client_data_lock.state = ConnectionState::Idle;
438 }
439 ConnectionState::Processing => {
440 // Queue event
441 client_data_lock.queued_events.push(event);
442 client_data_lock.state = ConnectionState::Accumulating;
443 }
444 ConnectionState::Accumulating => {
445 // Continue queuing
446 client_data_lock.queued_events.push(event);
447 }
448 }
449 }
450 }
451 Err(e) => {
452 error!("TURN client {} read error: {}", client_id, e);
453 app_state_clone
454 .update_client_status(client_id, ClientStatus::Error(e.to_string()))
455 .await;
456 let _ = status_clone.send("__UPDATE_UI__".to_string());
457 break;
458 }
459 }
460 }
461 // Every exit path lands here: drop the command handle so the dashboard stops
462 // offering [ send ] on a dead client. This also closes the command channel,
463 // which ends `command_loop`.
464 app_state_clone.remove_client_handle(client_id).await;
465 let _ = status_clone.send("__UPDATE_UI__".to_string());
466 });
467 task_registrar
468 .register_client_task(client_id, task_handle)
469 .await;
470
471 Ok(local_addr)
472 }
473
474 /// Drain injected commands until the channel closes (client removed, or the read loop
475 /// exited and dropped the handle) or an injected `disconnect` ends the session.
476 ///
477 /// The generic `command_support::handle_stream_client_command` cannot run this client's
478 /// vocabulary - every TURN verb yields `ClientActionResult::Custom` and has to be encoded
479 /// as a STUN/TURN message - so the action goes through [`Self::handle_action_result`],
480 /// the same function the LLM path uses, and the outcome is recorded and replied exactly
481 /// the way the generic arm does it.
482 #[allow(clippy::too_many_arguments)]
483 async fn command_loop(
484 mut command_rx: mpsc::Receiver<ClientCommand>,
485 socket: Arc<UdpSocket>,
486 remote_addr: SocketAddr,
487 protocol: Arc<TurnClientProtocol>,
488 client_data: Arc<Mutex<ClientData>>,
489 client_id: ClientId,
490 app_state: Arc<AppState>,
491 status_tx: mpsc::UnboundedSender<String>,
492 ) {
493 use crate::llm::actions::client_trait::Client;
494 use crate::llm::actions::protocol_trait::Protocol;
495
496 while let Some(command) = command_rx.recv().await {
497 let action = command.action.clone();
498 let outcome = match protocol.as_ref().execute_action(action.clone()) {
499 Err(e) => Ok(ClientSendOutcome::Rejected {
500 error: e.to_string(),
501 }),
502 Ok(action_result) => {
503 Self::handle_action_result(
504 action_result,
505 &socket,
506 remote_addr,
507 &client_data,
508 &status_tx,
509 client_id,
510 )
511 .await
512 }
513 };
514
515 let outcome_json = match &outcome {
516 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
517 Err(e) => serde_json::json!({"error": e.to_string()}),
518 };
519 app_state
520 .record_access_log(
521 AccessLogOwner::Client(client_id.as_u32()),
522 protocol.protocol_name(),
523 None,
524 "injected_action",
525 action,
526 vec![outcome_json],
527 )
528 .await;
529
530 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
531 if let Err(e) = &outcome {
532 error!("TURN client {} injected action failed: {}", client_id, e);
533 let _ = status_tx.send(format!(
534 "[WARN] Client {} injected action failed: {}",
535 client_id, e
536 ));
537 }
538 let _ = status_tx.send("__UPDATE_UI__".to_string());
539 crate::client::command_support::reply(command, outcome);
540
541 if disconnect {
542 // The Refresh(lifetime=0) that deletes the allocation has already gone out
543 // (that is what `handle_action_result` does for Disconnect). TURN runs over
544 // an unconnected UDP socket, so there is nothing further to close: stop
545 // accepting commands, drop the handle and mark the client disconnected. The
546 // recv loop's socket is released when the client is removed (`stop_client`
547 // aborts both registered tasks).
548 app_state.remove_client_handle(client_id).await;
549 app_state
550 .update_client_status(client_id, ClientStatus::Disconnected)
551 .await;
552 let _ = status_tx.send("__UPDATE_UI__".to_string());
553 break;
554 }
555 }
556 }
557
558 /// Encode one executed action as a TURN message and put it on the wire.
559 ///
560 /// Shared by the LLM path and injected commands so the STUN/TURN encoding exists exactly
561 /// once. The returned [`ClientSendOutcome`] reports what actually reached the wire:
562 /// `Sent` carries the real datagram length, `Executed` means the result produced no TURN
563 /// message, and `Err` means the datagram could not be built or sent.
564 async fn handle_action_result(
565 action_result: crate::llm::actions::client_trait::ClientActionResult,
566 socket: &Arc<UdpSocket>,
567 remote_addr: SocketAddr,
568 client_data: &Arc<Mutex<ClientData>>,
569 status_tx: &mpsc::UnboundedSender<String>,
570 client_id: ClientId,
571 ) -> Result<ClientSendOutcome> {
572 use crate::llm::actions::client_trait::ClientActionResult;
573
574 match action_result {
575 ClientActionResult::Custom { name, data } => match name.as_str() {
576 "allocate" => {
577 let lifetime = data
578 .get("lifetime_seconds")
579 .and_then(|v| v.as_u64())
580 .unwrap_or(600);
581
582 let message = Self::build_allocate_request(
583 u32::try_from(lifetime).context("TURN lifetime exceeds u32")?,
584 )?;
585 let sent = socket.send_to(&message, remote_addr).await?;
586
587 Log::new(Some(status_tx)).debug(format!(
588 "TURN client {} sent Allocate request (lifetime: {}s)",
589 client_id, lifetime
590 ));
591 Ok(ClientSendOutcome::Sent { bytes_sent: sent })
592 }
593 "create_permission" => {
594 let peer_address = data
595 .get("peer_address")
596 .and_then(|v| v.as_str())
597 .context("Missing peer_address")?;
598
599 let peer_addr: SocketAddr =
600 peer_address.parse().context("Invalid peer_address")?;
601
602 let (message, transaction_id) =
603 Self::build_create_permission_request(peer_addr)?;
604
605 // Remember the peer before the datagram leaves, so a fast
606 // response cannot arrive at the read loop first and find
607 // nothing to correlate against.
608 {
609 let mut state = client_data.lock().await;
610 if state.pending_permissions.len() >= MAX_PENDING_PERMISSIONS {
611 state.pending_permissions.clear();
612 }
613 state.pending_permissions.insert(transaction_id, peer_addr);
614 }
615
616 let sent = socket.send_to(&message, remote_addr).await?;
617
618 Log::new(Some(status_tx)).debug(format!(
619 "TURN client {} sent CreatePermission for {}",
620 client_id, peer_addr
621 ));
622 Ok(ClientSendOutcome::Sent { bytes_sent: sent })
623 }
624 "send_indication" => {
625 let peer_address = data
626 .get("peer_address")
627 .and_then(|v| v.as_str())
628 .context("Missing peer_address")?;
629
630 let peer_addr: SocketAddr =
631 peer_address.parse().context("Invalid peer_address")?;
632
633 let send_data = data
634 .get("data")
635 .and_then(|v| v.as_array())
636 .map(|arr| crate::client::wire_values::bytes(arr))
637 .context("Missing or invalid data")??;
638
639 let message = Self::build_send_indication(peer_addr, &send_data)?;
640 let sent = socket.send_to(&message, remote_addr).await?;
641
642 Log::new(Some(status_tx)).debug(format!(
643 "TURN client {} sent {} bytes via SendIndication to {}",
644 client_id,
645 send_data.len(),
646 peer_addr
647 ));
648 Ok(ClientSendOutcome::Sent { bytes_sent: sent })
649 }
650 "refresh" => {
651 let lifetime = data
652 .get("lifetime_seconds")
653 .and_then(|v| v.as_u64())
654 .unwrap_or(600);
655
656 let message = Self::build_refresh_request(
657 u32::try_from(lifetime).context("TURN lifetime exceeds u32")?,
658 )?;
659 let sent = socket.send_to(&message, remote_addr).await?;
660
661 Log::new(Some(status_tx)).debug(format!(
662 "TURN client {} sent Refresh request (lifetime: {}s)",
663 client_id, lifetime
664 ));
665 Ok(ClientSendOutcome::Sent { bytes_sent: sent })
666 }
667 other => {
668 debug!("TURN client {} unknown custom action: {}", client_id, other);
669 Ok(ClientSendOutcome::Executed {
670 detail: format!("custom result '{other}' builds no TURN message"),
671 })
672 }
673 },
674 ClientActionResult::Disconnect => {
675 // Send Refresh with lifetime=0 to delete allocation
676 let message = Self::build_refresh_request(0)?;
677 socket.send_to(&message, remote_addr).await?;
678
679 Log::new(Some(status_tx)).info(format!(
680 "TURN client {} disconnecting (sent Refresh with lifetime=0)",
681 client_id
682 ));
683 Ok(ClientSendOutcome::Disconnected)
684 }
685 other => Ok(ClientSendOutcome::Executed {
686 detail: format!("{other:?} sends no TURN message"),
687 }),
688 }
689 }
690
691 /// Build TURN Allocate Request message
692 fn build_allocate_request(lifetime_seconds: u32) -> Result<Vec<u8>> {
693 let mut message = Vec::new();
694
695 // STUN message type: Allocate Request (0x0003)
696 message.extend_from_slice(&0x0003u16.to_be_bytes());
697
698 // Message length (will update later)
699 let length_pos = message.len();
700 message.extend_from_slice(&0u16.to_be_bytes());
701
702 // Magic cookie
703 message.extend_from_slice(&0x2112A442u32.to_be_bytes());
704
705 // Transaction ID (12 random bytes)
706 let transaction_id: Vec<u8> = (0..12).map(|_| rand::random::<u8>()).collect();
707 message.extend_from_slice(&transaction_id);
708
709 // LIFETIME attribute (0x000D)
710 let attr_type = 0x000Du16;
711 let attr_length = 4u16;
712 message.extend_from_slice(&attr_type.to_be_bytes());
713 message.extend_from_slice(&attr_length.to_be_bytes());
714 message.extend_from_slice(&lifetime_seconds.to_be_bytes());
715
716 // REQUESTED-TRANSPORT attribute (0x0019) - UDP (17)
717 let attr_type = 0x0019u16;
718 let attr_length = 4u16;
719 message.extend_from_slice(&attr_type.to_be_bytes());
720 message.extend_from_slice(&attr_length.to_be_bytes());
721 message.push(17); // UDP protocol number
722 message.extend_from_slice(&[0, 0, 0]); // Reserved
723
724 // Update message length
725 let total_length =
726 u16::try_from(message.len() - 20).context("TURN message exceeds u16 length")?;
727 message[length_pos..length_pos + 2].copy_from_slice(&total_length.to_be_bytes());
728
729 Ok(message)
730 }
731
732 /// Build TURN CreatePermission Request message.
733 ///
734 /// Returns the message and its transaction ID (hex), because the Success
735 /// Response is empty and correlating the reply back to a peer is only
736 /// possible if the caller keeps that ID.
737 fn build_create_permission_request(peer_addr: SocketAddr) -> Result<(Vec<u8>, String)> {
738 let mut message = Vec::new();
739
740 // STUN message type: CreatePermission Request (0x0008)
741 message.extend_from_slice(&0x0008u16.to_be_bytes());
742
743 // Message length (will update later)
744 let length_pos = message.len();
745 message.extend_from_slice(&0u16.to_be_bytes());
746
747 // Magic cookie
748 message.extend_from_slice(&0x2112A442u32.to_be_bytes());
749
750 // Transaction ID
751 let transaction_id: Vec<u8> = (0..12).map(|_| rand::random::<u8>()).collect();
752 message.extend_from_slice(&transaction_id);
753
754 // XOR-PEER-ADDRESS attribute (0x0012)
755 Self::add_xor_peer_address(&mut message, peer_addr, &transaction_id)?;
756
757 // Update message length
758 let total_length =
759 u16::try_from(message.len() - 20).context("TURN message exceeds u16 length")?;
760 message[length_pos..length_pos + 2].copy_from_slice(&total_length.to_be_bytes());
761
762 Ok((message, hex::encode(&transaction_id)))
763 }
764
765 /// Build TURN SendIndication message
766 fn build_send_indication(peer_addr: SocketAddr, data: &[u8]) -> Result<Vec<u8>> {
767 let mut message = Vec::new();
768
769 // STUN message type: SendIndication (0x0016)
770 message.extend_from_slice(&0x0016u16.to_be_bytes());
771
772 // Message length (will update later)
773 let length_pos = message.len();
774 message.extend_from_slice(&0u16.to_be_bytes());
775
776 // Magic cookie
777 message.extend_from_slice(&0x2112A442u32.to_be_bytes());
778
779 // Transaction ID
780 let transaction_id: Vec<u8> = (0..12).map(|_| rand::random::<u8>()).collect();
781 message.extend_from_slice(&transaction_id);
782
783 // XOR-PEER-ADDRESS attribute (0x0012)
784 Self::add_xor_peer_address(&mut message, peer_addr, &transaction_id)?;
785
786 // DATA attribute (0x0013)
787 let attr_type = 0x0013u16;
788 let attr_length = u16::try_from(data.len()).context("TURN data exceeds u16 length")?;
789 message.extend_from_slice(&attr_type.to_be_bytes());
790 message.extend_from_slice(&attr_length.to_be_bytes());
791 message.extend_from_slice(data);
792
793 // Add padding if needed (attributes must be 4-byte aligned)
794 let padding = (4 - (data.len() % 4)) % 4;
795 message.extend_from_slice(&vec![0u8; padding]);
796
797 // Update message length
798 let total_length =
799 u16::try_from(message.len() - 20).context("TURN message exceeds u16 length")?;
800 message[length_pos..length_pos + 2].copy_from_slice(&total_length.to_be_bytes());
801
802 Ok(message)
803 }
804
805 /// Build TURN Refresh Request message
806 fn build_refresh_request(lifetime_seconds: u32) -> Result<Vec<u8>> {
807 let mut message = Vec::new();
808
809 // STUN message type: Refresh Request (0x0004)
810 message.extend_from_slice(&0x0004u16.to_be_bytes());
811
812 // Message length (will update later)
813 let length_pos = message.len();
814 message.extend_from_slice(&0u16.to_be_bytes());
815
816 // Magic cookie
817 message.extend_from_slice(&0x2112A442u32.to_be_bytes());
818
819 // Transaction ID
820 let transaction_id: Vec<u8> = (0..12).map(|_| rand::random::<u8>()).collect();
821 message.extend_from_slice(&transaction_id);
822
823 // LIFETIME attribute (0x000D)
824 let attr_type = 0x000Du16;
825 let attr_length = 4u16;
826 message.extend_from_slice(&attr_type.to_be_bytes());
827 message.extend_from_slice(&attr_length.to_be_bytes());
828 message.extend_from_slice(&lifetime_seconds.to_be_bytes());
829
830 // Update message length
831 let total_length =
832 u16::try_from(message.len() - 20).context("TURN message exceeds u16 length")?;
833 message[length_pos..length_pos + 2].copy_from_slice(&total_length.to_be_bytes());
834
835 Ok(message)
836 }
837
838 /// Add XOR-PEER-ADDRESS attribute to message
839 fn add_xor_peer_address(
840 message: &mut Vec<u8>,
841 peer_addr: SocketAddr,
842 transaction_id: &[u8],
843 ) -> Result<()> {
844 let attr_type = 0x0012u16;
845
846 match peer_addr {
847 SocketAddr::V4(addr) => {
848 let attr_length = 8u16; // Family (2) + Port (2) + IPv4 (4)
849 message.extend_from_slice(&attr_type.to_be_bytes());
850 message.extend_from_slice(&attr_length.to_be_bytes());
851
852 // Reserved byte + Family (0x01 for IPv4)
853 message.push(0x00);
854 message.push(0x01);
855
856 // X-Port (port XOR'd with most significant 16 bits of magic cookie)
857 let port = addr.port();
858 let xor_port = port ^ 0x2112;
859 message.extend_from_slice(&xor_port.to_be_bytes());
860
861 // X-Address (IP XOR'd with magic cookie)
862 let ip_bytes = addr.ip().octets();
863 let magic_cookie = 0x2112A442u32.to_be_bytes();
864 for i in 0..4 {
865 message.push(ip_bytes[i] ^ magic_cookie[i]);
866 }
867 }
868 SocketAddr::V6(addr) => {
869 let attr_length = 20u16; // Family (2) + Port (2) + IPv6 (16)
870 message.extend_from_slice(&attr_type.to_be_bytes());
871 message.extend_from_slice(&attr_length.to_be_bytes());
872
873 // Reserved byte + Family (0x02 for IPv6)
874 message.push(0x00);
875 message.push(0x02);
876
877 // X-Port
878 let port = addr.port();
879 let xor_port = port ^ 0x2112;
880 message.extend_from_slice(&xor_port.to_be_bytes());
881
882 // X-Address (IPv6 XOR'd with magic cookie + transaction ID)
883 let ip_bytes = addr.ip().octets();
884 let magic_cookie = 0x2112A442u32.to_be_bytes();
885
886 for i in 0..4 {
887 message.push(ip_bytes[i] ^ magic_cookie[i]);
888 }
889 for i in 4..16 {
890 message.push(ip_bytes[i] ^ transaction_id[i - 4]);
891 }
892 }
893 }
894
895 Ok(())
896 }
897
898 /// Parse TURN/STUN message header (similar to server implementation)
899 fn parse_turn_header(data: &[u8]) -> (Option<Vec<u8>>, String, bool) {
900 if data.len() < 20 {
901 return (None, "invalid".to_string(), false);
902 }
903
904 // Check magic cookie
905 let magic_cookie = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
906 if magic_cookie != 0x2112A442 {
907 return (None, "invalid".to_string(), false);
908 }
909
910 // Extract message type
911 let message_type_raw = u16::from_be_bytes([data[0], data[1]]);
912
913 // RFC 8489 section 5: the 14-bit type interleaves method and class as
914 // 0b00 M11 M10 M9 M8 M7 C1 M6 M5 M4 C0 M3 M2 M1 M0
915 // so C0 is bit 4, C1 is bit 8, and class == C1<<1 | C0.
916 //
917 // This used to read `((raw & 0x0110) >> 4) | ((raw & 0x0100) >> 7)`, which
918 // shifts bit 8 by 4 rather than 8 and works out to C0 + 18*C1. Every class
919 // with C1 set therefore decoded to 18 or 19 and fell through the table
920 // below to "Unknown". Since a *client* receives nothing but responses and
921 // indications, that meant this client could not parse a single reply: an
922 // Allocate success (0x0103) decoded to 18, `relay_address` was never
923 // stored, and turn_allocated / turn_refreshed / turn_permission_created /
924 // turn_data_received could not fire however correct the server was. The
925 // sibling server has this right (`src/server/turn/mod.rs`); the two
926 // disagreed, and the client was the wrong one.
927 //
928 // The table below was wrong to match: it expected class 1 for a success
929 // response and 2 for an error, which are the *indication* and *success*
930 // values. Both are corrected together, because fixing either alone leaves
931 // the client just as deaf.
932 let c0 = (message_type_raw >> 4) & 0x1;
933 let c1 = (message_type_raw >> 8) & 0x1;
934 let class = (c1 << 1) | c0;
935 let method = (message_type_raw & 0x000F)
936 | ((message_type_raw & 0x00E0) >> 1)
937 | ((message_type_raw & 0x3E00) >> 2);
938
939 // Class: 0 = request, 1 = indication, 2 = success response, 3 = error.
940 let message_type = match (class, method) {
941 (0, 3) => "AllocateRequest",
942 (2, 3) => "AllocateResponse",
943 (3, 3) => "AllocateError",
944 (0, 4) => "RefreshRequest",
945 (2, 4) => "RefreshResponse",
946 (3, 4) => "RefreshError",
947 (0, 8) => "CreatePermissionRequest",
948 (2, 8) => "CreatePermissionResponse",
949 (3, 8) => "CreatePermissionError",
950 (1, 6) => "SendIndication",
951 (1, 7) => "DataIndication",
952 _ => "Unknown",
953 };
954
955 let transaction_id = data[8..20].to_vec();
956
957 (Some(transaction_id), message_type.to_string(), true)
958 }
959
960 /// Walk the attributes of a STUN/TURN message, bounded by what actually arrived.
961 ///
962 /// **This is the only place attribute offsets are computed.** Four hand-rolled
963 /// copies of this walk used to exist — one each in `extract_xor_address`,
964 /// `extract_lifetime`, `extract_data_attribute` and `extract_error_code` — and
965 /// every one of them bounded the cursor against `20 + message_length`, a
966 /// 16-bit field the sender chooses, while indexing a buffer capped at 2048
967 /// bytes. Twenty bytes on the wire —
968 /// `00 07 FF FF 21 12 A4 42 <12-byte transaction id>` — index `data[20]` on a
969 /// 20-byte slice and **panic**. `tokio::spawn` swallows that panic, so the
970 /// read loop died silently while `AppState` went on reporting the client
971 /// `Connected` and the dashboard went on offering `[ send ]`: permanently deaf,
972 /// with nothing in the UI to say so. The socket is bound to `0.0.0.0:0` and the
973 /// loop never checks that a datagram came from the TURN server, so any host
974 /// that could reach the ephemeral port could send it.
975 ///
976 /// The rule, which the sibling server already followed, is to trust the
977 /// shorter of the declared length and what arrived.
978 fn attributes(data: &[u8]) -> Vec<(u16, &[u8])> {
979 if data.len() < 20 {
980 return Vec::new();
981 }
982
983 let declared = u16::from_be_bytes([data[2], data[3]]) as usize;
984 let end = 20usize.saturating_add(declared).min(data.len());
985
986 let mut attributes = Vec::new();
987 let mut pos = 20usize;
988 while pos + 4 <= end {
989 let attr_type = u16::from_be_bytes([data[pos], data[pos + 1]]);
990 let attr_len = u16::from_be_bytes([data[pos + 2], data[pos + 3]]) as usize;
991 let value_start = pos + 4;
992 let value_end = match value_start.checked_add(attr_len) {
993 Some(v) if v <= end => v,
994 // Truncated or lying length: stop, keep what we parsed.
995 _ => break,
996 };
997 attributes.push((attr_type, &data[value_start..value_end]));
998
999 // Attributes are padded to a 4-byte boundary. A zero-length attribute
1000 // still advances by the 4-byte header, so this cannot spin.
1001 let padded = attr_len.saturating_add(3) & !3usize;
1002 pos = match value_start.checked_add(padded) {
1003 Some(p) => p,
1004 None => break,
1005 };
1006 }
1007 attributes
1008 }
1009
1010 /// First attribute of `attr_type`, if present.
1011 fn attribute<'a>(data: &'a [u8], attr_type: u16) -> Option<&'a [u8]> {
1012 Self::attributes(data)
1013 .into_iter()
1014 .find(|(t, _)| *t == attr_type)
1015 .map(|(_, v)| v)
1016 }
1017
1018 /// Extract XOR-RELAYED-ADDRESS attribute from TURN message
1019 fn extract_xor_relayed_address(data: &[u8]) -> Option<SocketAddr> {
1020 Self::extract_xor_address(data, 0x0016) // XOR-RELAYED-ADDRESS = 0x0016
1021 }
1022
1023 /// Extract XOR-PEER-ADDRESS attribute from TURN message
1024 fn extract_xor_peer_address(data: &[u8]) -> Option<SocketAddr> {
1025 Self::extract_xor_address(data, 0x0012) // XOR-PEER-ADDRESS = 0x0012
1026 }
1027
1028 /// Extract XOR'd address attribute from TURN message
1029 fn extract_xor_address(data: &[u8], attr_type: u16) -> Option<SocketAddr> {
1030 let attr_data = Self::attribute(data, attr_type)?;
1031 if attr_data.len() < 4 {
1032 return None;
1033 }
1034
1035 let family = attr_data[1];
1036 let xor_port = u16::from_be_bytes([attr_data[2], attr_data[3]]);
1037 let port = xor_port ^ 0x2112;
1038
1039 match family {
1040 0x01 => {
1041 // IPv4
1042 if attr_data.len() < 8 {
1043 return None;
1044 }
1045
1046 let magic_cookie = 0x2112A442u32.to_be_bytes();
1047 let xor_ip = &attr_data[4..8];
1048 let ip_bytes = [
1049 xor_ip[0] ^ magic_cookie[0],
1050 xor_ip[1] ^ magic_cookie[1],
1051 xor_ip[2] ^ magic_cookie[2],
1052 xor_ip[3] ^ magic_cookie[3],
1053 ];
1054
1055 let ip = std::net::Ipv4Addr::from(ip_bytes);
1056 Some(SocketAddr::from((ip, port)))
1057 }
1058 0x02 => {
1059 // IPv6
1060 if attr_data.len() < 20 {
1061 return None;
1062 }
1063
1064 let magic_cookie = 0x2112A442u32.to_be_bytes();
1065 let transaction_id = &data[8..20];
1066 let xor_ip = &attr_data[4..20];
1067
1068 let mut ip_bytes = [0u8; 16];
1069 for i in 0..4 {
1070 ip_bytes[i] = xor_ip[i] ^ magic_cookie[i];
1071 }
1072 for i in 4..16 {
1073 ip_bytes[i] = xor_ip[i] ^ transaction_id[i - 4];
1074 }
1075
1076 let ip = std::net::Ipv6Addr::from(ip_bytes);
1077 Some(SocketAddr::from((ip, port)))
1078 }
1079 _ => None,
1080 }
1081 }
1082
1083 /// Extract LIFETIME attribute from TURN message
1084 fn extract_lifetime(data: &[u8]) -> Option<u32> {
1085 let value = Self::attribute(data, 0x000D)?;
1086 if value.len() < 4 {
1087 return None;
1088 }
1089 Some(u32::from_be_bytes([value[0], value[1], value[2], value[3]]))
1090 }
1091
1092 /// Extract DATA attribute from TURN message
1093 fn extract_data_attribute(data: &[u8]) -> Option<Vec<u8>> {
1094 Self::attribute(data, 0x0013).map(|v| v.to_vec())
1095 }
1096
1097 /// Extract ERROR-CODE attribute from TURN message
1098 ///
1099 /// RFC 8489 section 14.8: two reserved bytes, then a 3-bit class and a
1100 /// number 0-99.
1101 fn extract_error_code(data: &[u8]) -> Option<u16> {
1102 let value = Self::attribute(data, 0x0009)?;
1103 if value.len() < 4 {
1104 return None;
1105 }
1106 let class = (value[2] & 0x07) as u16;
1107 let number = value[3] as u16;
1108 Some(class * 100 + number)
1109 }
1110}