netget/server/stun/mod.rs
1//! STUN server implementation
2pub mod actions;
3
4use crate::server::connection::ConnectionId;
5use anyhow::Result;
6use std::net::SocketAddr;
7use std::sync::Arc;
8use tokio::net::UdpSocket;
9use tokio::sync::mpsc;
10use tracing::debug;
11
12use crate::llm::action_helper::call_llm;
13use crate::llm::actions::protocol_trait::{ActionResult, Server};
14use crate::llm::ollama_client::OllamaClient;
15use crate::logging::emit::Log;
16use crate::protocol::Event;
17use crate::server::StunProtocol;
18use crate::state::app_state::AppState;
19use crate::{console_debug, console_trace};
20use actions::STUN_BINDING_REQUEST_EVENT;
21
22/// STUN server that handles binding requests
23pub struct StunServer;
24
25impl StunServer {
26 /// Spawn STUN server with integrated LLM actions
27 pub async fn spawn_with_llm_actions(
28 listen_addr: SocketAddr,
29 llm_client: OllamaClient,
30 app_state: Arc<AppState>,
31 status_tx: mpsc::UnboundedSender<String>,
32 server_id: crate::state::ServerId,
33 ) -> Result<SocketAddr> {
34 let socket = Arc::new(UdpSocket::bind(listen_addr).await?);
35 let local_addr = socket.local_addr()?;
36 let log = Log::new(Some(&status_tx));
37 log.info(format!(
38 "STUN server (action-based) listening on {}",
39 local_addr
40 ));
41 log.debug(format!(
42 "STUN socket bound - requested: {}, actual: {}",
43 listen_addr, local_addr
44 ));
45
46 let protocol = Arc::new(StunProtocol::new());
47
48 let task_registrar = app_state.clone();
49 let accept_handle = tokio::spawn(async move {
50 let mut buffer = vec![0u8; 2048]; // STUN messages are typically < 2KB
51
52 Log::new(Some(&status_tx)).debug("STUN receive loop started, waiting for packets...");
53
54 loop {
55 debug!("STUN calling recv_from...");
56 Log::new(Some(&status_tx)).trace("STUN about to call recv_from (iteration)");
57 match socket.recv_from(&mut buffer).await {
58 Ok((n, peer_addr)) => {
59 Log::new(Some(&status_tx)).trace(format!(
60 "STUN recv_from returned OK: {} bytes from {}",
61 n, peer_addr
62 ));
63 let data = buffer[..n].to_vec();
64 let connection_id =
65 ConnectionId::new(app_state.get_next_unified_id().await);
66
67 // Add connection to ServerInstance (STUN "connection" = transaction)
68 use crate::state::server::{
69 ConnectionState as ServerConnectionState, ConnectionStatus,
70 ProtocolConnectionInfo,
71 };
72 let now = crate::utils::clock::Instant::now();
73 let conn_state = ServerConnectionState {
74 id: connection_id,
75 remote_addr: peer_addr,
76 local_addr,
77 bytes_sent: 0,
78 bytes_received: n as u64,
79 packets_sent: 0,
80 packets_received: 1,
81 last_activity: now,
82 status: ConnectionStatus::Active,
83 status_changed_at: now,
84 protocol_info: ProtocolConnectionInfo::empty(),
85 };
86 app_state
87 .add_connection_to_server(server_id, conn_state)
88 .await;
89 let _ = status_tx.send("__UPDATE_UI__".to_string());
90
91 // DEBUG: Log summary
92 console_debug!(status_tx, "STUN received {} bytes from {}", n, peer_addr);
93
94 // TRACE: Log full payload
95 let hex_str = hex::encode(&data);
96 console_trace!(status_tx, "STUN data (hex): {}", hex_str);
97
98 let llm_clone = llm_client.clone();
99 let state_clone = app_state.clone();
100 let status_clone = status_tx.clone();
101 let socket_clone = socket.clone();
102 let protocol_clone = protocol.clone();
103
104 // Tracked, not detached: stop_server must abort this task too.
105 let task_owner = app_state.clone();
106 task_owner.spawn_server_task(server_id, async move {
107 // Parse STUN message to extract transaction ID and message type
108 let (transaction_id, message_type, is_valid) =
109 Self::parse_stun_header(&data);
110
111 if !is_valid {
112 // Decided by the protocol, before any model call: RFC 8489
113 // says a server answers Binding *Requests* and nothing else,
114 // and answering anything else makes this a reflector.
115 Log::new(Some(&status_clone)).debug(format!(
116 "STUN dropped an unservable {} message from {} \
117 decision=protocol_error — only Binding requests are \
118 answered (see parse_stun_header)",
119 message_type, peer_addr
120 ));
121 return;
122 }
123
124 let transaction_id_hex =
125 transaction_id.as_ref().map(hex::encode).unwrap_or_default();
126
127 // Create STUN binding request event
128 let event_data = serde_json::json!({
129 "peer_addr": peer_addr.to_string(),
130 "local_addr": local_addr.to_string(),
131 "transaction_id": transaction_id_hex,
132 "message_type": message_type,
133 "bytes_received": data.len()
134 });
135
136 let event = Event::new(&STUN_BINDING_REQUEST_EVENT, event_data);
137
138 // A STUN Binding response is 100% mechanical: reflect the source
139 // address into XOR-MAPPED-ADDRESS and echo the 96-bit transaction ID.
140 // There is nothing to decide, so by default we answer statically with
141 // NO LLM round-trip. The model is consulted only when the operator
142 // opts in — a server instruction or a per-event handler — which is
143 // how one asks for non-standard behaviour (e.g. lying about the
144 // mapped address for NAT testing).
145 let wants_dynamic = operator_wants_dynamic(
146 &state_clone,
147 server_id,
148 &event.event_type.id,
149 )
150 .await;
151
152 if !wants_dynamic {
153 send_static_binding_response(
154 protocol_clone.as_ref(),
155 socket_clone.as_ref(),
156 peer_addr,
157 &transaction_id_hex,
158 &status_clone,
159 "static_default",
160 )
161 .await;
162 return;
163 }
164
165 Log::new(Some(&status_clone)).debug(format!(
166 "STUN calling LLM for binding request from {}",
167 peer_addr
168 ));
169
170 match call_llm(
171 &llm_clone,
172 &state_clone,
173 server_id,
174 None, // STUN uses UDP, no persistent connection
175 &event,
176 protocol_clone.as_ref(),
177 )
178 .await
179 {
180 Ok(execution_result) => {
181 let log = Log::new(Some(&status_clone));
182
183 // Display messages from LLM
184 for message in &execution_result.messages {
185 log.info(message);
186 }
187
188 log.debug(format!(
189 "STUN parsed {} actions",
190 execution_result.raw_actions.len()
191 ));
192
193 // Process protocol results
194 log.debug(format!(
195 "STUN got {} protocol results",
196 execution_result.protocol_results.len()
197 ));
198
199 // Classify the answer before consuming it: `ignore_request`
200 // is the model deciding to say nothing, which must stay
201 // distinguishable from the model having said nothing.
202 let action_count = execution_result.raw_actions.len();
203 let explicitly_ignored =
204 execution_result.raw_actions.iter().any(|a| {
205 a.get("type").and_then(|v| v.as_str())
206 == Some("ignore_request")
207 });
208 let mut datagrams_sent = 0usize;
209
210 for protocol_result in execution_result.protocol_results {
211 if let Some(output_data) =
212 protocol_result.get_all_output().first()
213 {
214 let _ =
215 socket_clone.send_to(output_data, peer_addr).await;
216 datagrams_sent += 1;
217
218 // DEBUG: Log summary
219 log.debug(format!(
220 "STUN sent {} bytes to {}",
221 output_data.len(),
222 peer_addr
223 ));
224
225 // TRACE: Log full payload
226 let hex_str = hex::encode(output_data);
227 log.trace(format!("STUN sent (hex): {}", hex_str));
228
229 let _ = status_clone.send(format!(
230 "→ STUN response to {} ({} bytes)",
231 peer_addr,
232 output_data.len()
233 ));
234 } else {
235 log.debug("STUN protocol result has no output data");
236 }
237 }
238
239 // One grep-able line per request saying how it was decided.
240 // Silence and refusal both leave the client waiting out its
241 // retransmission schedule, so the log is the only place the
242 // two can be told apart.
243 if datagrams_sent > 0 {
244 log.info(format!(
245 "STUN binding request from {} ({}) \
246 decision=model_answer ({} datagram(s) sent)",
247 peer_addr, connection_id, datagrams_sent
248 ));
249 } else if explicitly_ignored {
250 log.info(format!(
251 "STUN binding request from {} ({}) \
252 decision=model_reject (ignore_request: the model \
253 chose to send nothing)",
254 peer_addr, connection_id
255 ));
256 } else {
257 log.warn(format!(
258 "STUN binding request from {} ({}) \
259 decision=model_silent actions={} — nothing was \
260 sent and the client will time out",
261 peer_addr, connection_id, action_count
262 ));
263 }
264 }
265 Err(e) => {
266 // Fail closed to the correct static default, not to a
267 // permissive or misleading answer. The operator opted into
268 // LLM control and the LLM failed; the mechanical response
269 // (the client's real reflected address) is the safe fallback
270 // here — a STUN Binding response is never a credential or an
271 // approval — so send it and say so, rather than dropping the
272 // request or inventing an address.
273 //
274 // The token is deliberately NOT `fail_closed_*`: the peer
275 // gets an affirmative Binding Success Response here, and a
276 // tag claiming otherwise would be a lie. The error and its
277 // overload category go to the log only — never onto the
278 // wire, where a STUN message has no free-text field anyway.
279 let category =
280 match crate::utils::wire_failure::WireFailure::classify(&e)
281 {
282 crate::utils::wire_failure::WireFailure::Overloaded => {
283 "overloaded"
284 }
285 crate::utils::wire_failure::WireFailure::Unavailable => {
286 "unavailable"
287 }
288 };
289 Log::new(Some(&status_clone)).error(format!(
290 "✗ STUN LLM call failed for request from {} ({}) \
291 category={}: {}",
292 peer_addr, connection_id, category, e
293 ));
294 send_static_binding_response(
295 protocol_clone.as_ref(),
296 socket_clone.as_ref(),
297 peer_addr,
298 &transaction_id_hex,
299 &status_clone,
300 "static_fallback_llm_error",
301 )
302 .await;
303 }
304 }
305 }).await;
306 }
307 Err(e) => {
308 Log::new(Some(&status_tx)).error(format!("STUN receive error: {}", e));
309 break;
310 }
311 }
312 }
313 });
314
315 task_registrar
316 .register_server_task(server_id, accept_handle)
317 .await;
318
319 Ok(local_addr)
320 }
321
322 /// Parse STUN message header to extract transaction ID and message type.
323 ///
324 /// Returns `(transaction_id, message_type_string, servable)`. `servable` is
325 /// **not** "this parsed" — it is "this server may answer it". Everything
326 /// else is dropped without a byte leaving the socket, because on UDP a reply
327 /// goes to whatever source address the datagram claimed.
328 fn parse_stun_header(data: &[u8]) -> (Option<Vec<u8>>, String, bool) {
329 // STUN message header is 20 bytes minimum
330 if data.len() < 20 {
331 return (None, "invalid".to_string(), false);
332 }
333
334 // RFC 8489 section 5: the two most significant bits of a STUN message
335 // type are always zero. Anything else is not STUN — 0b01 there is a TURN
336 // ChannelData frame — and must not be answered.
337 if u16::from_be_bytes([data[0], data[1]]) & 0xC000 != 0 {
338 return (None, "invalid".to_string(), false);
339 }
340
341 // Check magic cookie (bytes 4-7 should be 0x2112A442)
342 let magic_cookie = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
343 if magic_cookie != 0x2112A442 {
344 return (None, "invalid".to_string(), false);
345 }
346
347 // RFC 8489 section 5: the message length counts the attributes only and
348 // is always a multiple of 4. A header whose declared length overruns the
349 // datagram is malformed, and answering it would mean answering a source
350 // address on the strength of a header we already know is lying.
351 let declared_length = u16::from_be_bytes([data[2], data[3]]) as usize;
352 if declared_length % 4 != 0 || 20usize.saturating_add(declared_length) > data.len() {
353 return (None, "invalid".to_string(), false);
354 }
355
356 // Extract message type (first 2 bytes)
357 let message_type_raw = u16::from_be_bytes([data[0], data[1]]);
358
359 // RFC 8489 section 5: the 14-bit type interleaves method and class bits as
360 // 0b00 M11 M10 M9 M8 M7 C1 M6 M5 M4 C0 M3 M2 M1 M0
361 // so C0 is bit 4 and C1 is bit 8, and class == C1<<1 | C0.
362 //
363 // The previous expression, ((raw & 0x0110) >> 4) | ((raw & 0x0100) >> 7),
364 // works out to C0 + 18*C1, which happens to be right only when C1 is 0
365 // (requests and indications). Success and error responses decoded to 18
366 // and 19, so their arms below were unreachable and every response was
367 // labelled "Unknown".
368 let c0 = (message_type_raw >> 4) & 0x1;
369 let c1 = (message_type_raw >> 8) & 0x1;
370 let class = (c1 << 1) | c0;
371 let method = (message_type_raw & 0x000F)
372 | ((message_type_raw & 0x00E0) >> 1)
373 | ((message_type_raw & 0x3E00) >> 2);
374
375 // Class values: 0 = request, 1 = indication, 2 = success response,
376 // 3 = error response.
377 let message_type = match (class, method) {
378 (0, 1) => "BindingRequest",
379 (1, 1) => "BindingIndication",
380 (2, 1) => "BindingResponse",
381 (3, 1) => "BindingError",
382 _ => "Unknown",
383 };
384
385 // Extract transaction ID (12 bytes, from byte 8 to 19)
386 let transaction_id = data[8..20].to_vec();
387
388 // Only a Binding *Request* is served. That is RFC 8489 — section 6.3.2
389 // discards an indication without replying, and a server never receives a
390 // response for a transaction it did not start — and it is also what keeps
391 // this server from being a reflector.
392 //
393 // Until now any message carrying the magic cookie was answered, a Binding
394 // *Response* included. One spoofed datagram naming a second NetGet STUN
395 // server as its source therefore set the two answering each other's
396 // answers, with nothing in either to stop it. Aimed at any other UDP
397 // listener the same spoof makes this a ~2.4x amplifier (a 20-byte request
398 // produces a 48-byte reply) for as long as the attacker keeps sending.
399 // Dropping every class but Request costs nothing a real client does.
400 let servable = class == 0 && method == 1;
401
402 (Some(transaction_id), message_type.to_string(), servable)
403 }
404}
405
406/// Returns true if the operator opted into dynamic (LLM- or handler-driven) responses for
407/// this server: either a non-empty server instruction was given, or an event handler is
408/// configured for `event_id`. When false the protocol answers with a correct static default
409/// and never consults the model — a STUN Binding response is fully determined by the request.
410async fn operator_wants_dynamic(
411 state: &AppState,
412 server_id: crate::state::ServerId,
413 event_id: &str,
414) -> bool {
415 state
416 .with_server_mut(server_id, |server| {
417 let has_instruction = !server.instruction.trim().is_empty();
418 let has_handler = server
419 .event_handler_config
420 .as_ref()
421 .map(|c| c.find_handler(event_id).is_some())
422 .unwrap_or(false);
423 has_instruction || has_handler
424 })
425 .await
426 .unwrap_or(false)
427}
428
429/// Build and send the mechanical STUN Binding Success Response with no LLM involvement:
430/// XOR-MAPPED-ADDRESS = the client's own source address, transaction ID echoed verbatim.
431///
432/// `decision` is the grep-able token for *why* this response is the one being sent —
433/// `static_default` (the operator never opted into model control, so no model was asked) or
434/// `static_fallback_llm_error` (they did, and the backend failed). It is emitted on the same
435/// line as the bytes, so one line per request says both what went out and what decided it.
436async fn send_static_binding_response(
437 protocol: &StunProtocol,
438 socket: &UdpSocket,
439 peer_addr: SocketAddr,
440 transaction_id_hex: &str,
441 status: &mpsc::UnboundedSender<String>,
442 decision: &str,
443) {
444 let action = serde_json::json!({
445 "type": "send_stun_binding_response",
446 "transaction_id": transaction_id_hex,
447 "mapped_address": peer_addr.to_string(),
448 "xor_mapped_address": true
449 });
450 match protocol.execute_action(action) {
451 Ok(ActionResult::Output(bytes)) => {
452 let _ = socket.send_to(&bytes, peer_addr).await;
453 debug!(
454 "STUN sent static binding response ({} bytes) to {}",
455 bytes.len(),
456 peer_addr
457 );
458 Log::new(Some(status)).info(format!(
459 "→ STUN static binding response to {} ({} bytes) decision={}",
460 peer_addr,
461 bytes.len(),
462 decision
463 ));
464 }
465 Ok(_) => {
466 Log::new(Some(status)).warn(format!(
467 "STUN static binding response produced no output for {} \
468 decision=fail_closed_no_action (nothing sent)",
469 peer_addr
470 ));
471 }
472 Err(e) => {
473 // Our own mechanical response failed to build, so nothing goes out at all. The
474 // client sees a lost datagram; the log is the only record that it was not.
475 Log::new(Some(status)).error(format!(
476 "✗ STUN failed to build the static binding response for {} \
477 decision=fail_closed_action_error: {}",
478 peer_addr, e
479 ));
480 }
481 }
482}