1use crate::llm::actions::{
4 protocol_trait::{ActionResult, Server},
5 ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14pub struct StunProtocol;
15
16impl StunProtocol {
17 pub fn new() -> Self {
18 Self
19 }
20}
21
22impl crate::llm::actions::protocol_trait::Protocol for StunProtocol {
23 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
24 Vec::new() }
26
27 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
28 vec![
29 send_stun_binding_response_action(),
30 send_stun_error_response_action(),
31 ignore_request_action(),
32 ]
33 }
34
35 fn protocol_name(&self) -> &'static str {
36 "STUN"
37 }
38
39 fn get_event_types(&self) -> Vec<EventType> {
40 get_stun_event_types()
41 }
42
43 fn stack_name(&self) -> &'static str {
44 "ETH>IP>UDP>STUN"
45 }
46
47 fn keywords(&self) -> Vec<&'static str> {
48 vec!["stun"]
49 }
50
51 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
52 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
53
54 ProtocolMetadataV2::builder()
55 .connectionless()
56 .answers_on_failure()
62 .state(DevelopmentState::Beta)
63 .well_known_udp_port(3478)
64 .implementation("Manual STUN protocol (RFC 8489)")
65 .llm_control("Optional: Binding responses are static by default (mechanical), LLM only on opt-in")
66 .e2e_testing(
67 "REAL THIRD-PARTY CLIENT: stuntman 1.2.16's `stunclient` binary, in \
68 tests/server/stun/real_client_test.rs. NOT #[ignore]d and NOT skip-gated — the \
69 test FAILS, naming `brew install stuntman`, when the binary is absent. Not \
70 circular: this server is a hand-written RFC 8489 codec with no STUN library \
71 behind it, and the `stunclient` *crate* in Cargo.toml belongs to the STUN \
72 *client* (src/client/stun/), a different program that does not run in these \
73 tests. stuntman completes a full Binding transaction and must un-XOR our \
74 XOR-MAPPED-ADDRESS against the magic cookie and its own transaction ID to \
75 recover the address it prints; both tests assert the address it recovers is \
76 exactly the socket it sent from, which a wrong byte anywhere would break. Both \
77 encoders are covered: the STATIC path (empty instruction, zero LLM calls) and \
78 the ACTION path (send_stun_binding_response building the attribute from \
79 model-supplied strings). Also covered, without a third-party client: \
80 reflection_test.rs (only a Binding REQUEST is answered), static_default_test.rs \
81 (zero LLM calls on the mechanical path) and llm_failure_test.rs \
82 (decision=static_fallback_llm_error). NOT PROVEN: IPv6 XOR-MAPPED-ADDRESS \
83 against a real client (stunclient is driven over IPv4 loopback here, though the \
84 IPv6 encoder exists and is unit-covered); any authenticated exchange, since \
85 MESSAGE-INTEGRITY/USERNAME/REALM/NONCE/FINGERPRINT are not implemented and no \
86 action can add them, so no client can be made to authenticate; stuntman's NAT \
87 behaviour and filtering tests (-b/-f), which need the second address and port \
88 pair RFC 5780 wants and this server does not implement; and behaviour under \
89 any real NAT, everything here being loopback.",
90 )
91 .notes(
92 "Stateless UDP; IPv4 and IPv6 XOR-MAPPED-ADDRESS are both encoded. A Binding \
93 response is fully determined by the request (reflect source into \
94 XOR-MAPPED-ADDRESS, echo the transaction ID), so it is answered STATICALLY with \
95 no LLM round-trip by default. The LLM is consulted only when the operator opts \
96 in with a server instruction or a per-event handler — the way to request \
97 non-standard behaviour such as lying about the mapped address. On LLM failure in \
98 opt-in mode the server falls back to the correct static response. REFLECTION: \
99 only a Binding REQUEST is answered; responses, indications and other methods are \
100 dropped silently, so this cannot be looped against another STUN server. It is \
101 still a ~2.4x amplifier for a spoofed source (20-byte request, 48-byte reply), \
102 which is inherent to STUN and no worse than a real STUN server, and there is NO \
103 per-source rate limit — put one in front of it on an untrusted network. No \
104 authentication: MESSAGE-INTEGRITY, USERNAME, REALM, NONCE and FINGERPRINT are \
105 not implemented and no action can add them.",
106 )
107 .build()
108 }
109
110 fn description(&self) -> &'static str {
111 "STUN server for NAT traversal"
112 }
113
114 fn example_prompt(&self) -> &'static str {
115 "Start a STUN server for NAT traversal on port 3478"
116 }
117
118 fn group_name(&self) -> &'static str {
119 "Proxy & Network"
120 }
121 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
122 use crate::llm::actions::StartupExamples;
123 use serde_json::json;
124
125 let script = r#"import json, sys
128data = json.load(sys.stdin)
129event = data["event"]
130if data["event_type_id"] == "stun_binding_request":
131 actions = [{"type": "send_stun_binding_response",
132 "transaction_id": event.get("transaction_id"),
133 "mapped_address": event.get("peer_addr"),
134 "xor_mapped_address": True}]
135else:
136 actions = []
137print(json.dumps({"actions": actions}))"#;
138
139 StartupExamples::new(
140 json!({
142 "type": "open_server",
143 "port": 3478,
144 "base_stack": "stun",
145 "instruction": "STUN server for NAT traversal - respond with client's external IP"
146 }),
147 json!({
149 "type": "open_server",
150 "port": 3478,
151 "base_stack": "stun",
152 "event_handlers": [{
153 "event_pattern": "stun_binding_request",
154 "handler": {
155 "type": "script",
156 "language": "python",
157 "code": script
158 }
159 }]
160 }),
161 json!({
167 "type": "open_server",
168 "port": 3478,
169 "base_stack": "stun",
170 "event_handlers": [{
171 "event_pattern": "stun_binding_request",
172 "handler": {
173 "type": "static",
174 "actions": [{
175 "type": "send_stun_binding_response",
176 "transaction_id": "{{event.transaction_id}}",
177 "mapped_address": "{{event.peer_addr}}",
178 "xor_mapped_address": true
179 }]
180 }
181 }]
182 }),
183 )
184 }
185}
186
187impl Server for StunProtocol {
188 fn spawn(
189 &self,
190 ctx: crate::protocol::SpawnContext,
191 ) -> std::pin::Pin<
192 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
193 > {
194 Box::pin(async move {
195 use crate::server::stun::StunServer;
196 StunServer::spawn_with_llm_actions(
197 ctx.legacy_listen_addr(),
198 ctx.llm_client,
199 ctx.state,
200 ctx.status_tx,
201 ctx.server_id,
202 )
203 .await
204 })
205 }
206
207 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
208 let action_type = action
209 .get("type")
210 .and_then(|v| v.as_str())
211 .context("Missing 'type' field in action")?;
212
213 match action_type {
214 "send_stun_binding_response" => self.execute_send_binding_response(action),
215 "send_stun_error_response" => self.execute_send_error_response(action),
216 "ignore_request" => Ok(ActionResult::NoAction),
217 _ => Err(anyhow::anyhow!("Unknown STUN action: {}", action_type)),
218 }
219 }
220}
221
222impl StunProtocol {
223 fn execute_send_binding_response(&self, action: serde_json::Value) -> Result<ActionResult> {
225 let mapped_address = action
227 .get("mapped_address")
228 .and_then(|v| v.as_str())
229 .context("Missing 'mapped_address' field")?;
230
231 let transaction_id = action
232 .get("transaction_id")
233 .and_then(|v| v.as_str())
234 .context("Missing 'transaction_id' field")?;
235
236 let xor_mapped_address = action
238 .get("xor_mapped_address")
239 .and_then(|v| v.as_bool())
240 .unwrap_or(true);
241
242 let software = action
243 .get("software")
244 .and_then(|v| v.as_str())
245 .unwrap_or("NetGet/1.0");
246
247 let transaction_id_bytes =
249 hex::decode(transaction_id).context("Invalid transaction_id hex")?;
250
251 if transaction_id_bytes.len() != 12 {
252 return Err(anyhow::anyhow!("Transaction ID must be 12 bytes"));
253 }
254
255 let addr: std::net::SocketAddr = mapped_address
257 .parse()
258 .context("Invalid mapped_address format")?;
259
260 let packet = Self::build_binding_response(
262 &transaction_id_bytes,
263 addr,
264 xor_mapped_address,
265 software,
266 )?;
267
268 Ok(ActionResult::Output(packet))
269 }
270
271 fn execute_send_error_response(&self, action: serde_json::Value) -> Result<ActionResult> {
273 let error_code_raw = action
278 .get("error_code")
279 .and_then(|v| v.as_u64())
280 .context("Missing or non-numeric 'error_code' field")?;
281
282 if !(300..700).contains(&error_code_raw) {
286 return Err(anyhow::anyhow!(
287 "error_code {error_code_raw} is outside the 300-699 range RFC 8489 section \
288 14.8 encodes as a class/number pair"
289 ));
290 }
291 let error_code = error_code_raw as u16;
292
293 if !(300..700).contains(&error_code) {
294 return Err(anyhow::anyhow!(
295 "error_code {error_code} is outside the 300-699 range RFC 8489 section 14.8 \
296 encodes as a class/number pair"
297 ));
298 }
299
300 let reason = action
301 .get("reason")
302 .and_then(|v| v.as_str())
303 .context("Missing 'reason' field")?;
304
305 let transaction_id = action
306 .get("transaction_id")
307 .and_then(|v| v.as_str())
308 .context("Missing 'transaction_id' field")?;
309
310 let transaction_id_bytes =
312 hex::decode(transaction_id).context("Invalid transaction_id hex")?;
313
314 if transaction_id_bytes.len() != 12 {
315 return Err(anyhow::anyhow!("Transaction ID must be 12 bytes"));
316 }
317
318 let packet = Self::build_error_response(&transaction_id_bytes, error_code, reason)?;
320
321 Ok(ActionResult::Output(packet))
322 }
323
324 fn build_binding_response(
335 transaction_id: &[u8],
336 mapped_addr: std::net::SocketAddr,
337 use_xor: bool,
338 software: &str,
339 ) -> Result<Vec<u8>> {
340 let mut packet = Vec::new();
341
342 packet.extend_from_slice(&0x0101u16.to_be_bytes());
344
345 let length_pos = packet.len();
347 packet.extend_from_slice(&0u16.to_be_bytes());
348
349 packet.extend_from_slice(&0x2112A442u32.to_be_bytes());
351
352 packet.extend_from_slice(transaction_id);
354
355 let attributes_start = packet.len();
356
357 if use_xor {
359 Self::add_xor_mapped_address_attribute(&mut packet, mapped_addr, transaction_id)?;
360 } else {
361 Self::add_mapped_address_attribute(&mut packet, mapped_addr)?;
362 }
363
364 Self::add_software_attribute(&mut packet, software)?;
366
367 let attributes_length = (packet.len() - attributes_start) as u16;
369 packet[length_pos..length_pos + 2].copy_from_slice(&attributes_length.to_be_bytes());
370
371 Ok(packet)
372 }
373
374 pub(crate) fn build_error_response(
384 transaction_id: &[u8],
385 error_code: u16,
386 reason: &str,
387 ) -> Result<Vec<u8>> {
388 let mut packet = Vec::new();
389
390 packet.extend_from_slice(&0x0111u16.to_be_bytes());
392
393 let length_pos = packet.len();
395 packet.extend_from_slice(&0u16.to_be_bytes());
396
397 packet.extend_from_slice(&0x2112A442u32.to_be_bytes());
399
400 packet.extend_from_slice(transaction_id);
402
403 let attributes_start = packet.len();
404
405 Self::add_error_code_attribute(&mut packet, error_code, reason)?;
407
408 let attributes_length = (packet.len() - attributes_start) as u16;
410 packet[length_pos..length_pos + 2].copy_from_slice(&attributes_length.to_be_bytes());
411
412 Ok(packet)
413 }
414
415 fn add_mapped_address_attribute(
417 packet: &mut Vec<u8>,
418 addr: std::net::SocketAddr,
419 ) -> Result<()> {
420 packet.extend_from_slice(&0x0001u16.to_be_bytes());
422
423 let attr_start = packet.len();
425 packet.extend_from_slice(&0u16.to_be_bytes()); let value_start = packet.len();
428
429 match addr {
431 std::net::SocketAddr::V4(addr_v4) => {
432 packet.push(0x00); packet.push(0x01); packet.extend_from_slice(&addr_v4.port().to_be_bytes());
435 packet.extend_from_slice(&addr_v4.ip().octets());
436 }
437 std::net::SocketAddr::V6(addr_v6) => {
438 packet.push(0x00); packet.push(0x02); packet.extend_from_slice(&addr_v6.port().to_be_bytes());
441 packet.extend_from_slice(&addr_v6.ip().octets());
442 }
443 }
444
445 let value_length = (packet.len() - value_start) as u16;
446 packet[attr_start..attr_start + 2].copy_from_slice(&value_length.to_be_bytes());
447
448 Self::add_padding(packet);
450
451 Ok(())
452 }
453
454 fn add_xor_mapped_address_attribute(
456 packet: &mut Vec<u8>,
457 addr: std::net::SocketAddr,
458 transaction_id: &[u8],
459 ) -> Result<()> {
460 packet.extend_from_slice(&0x0020u16.to_be_bytes());
462
463 let attr_start = packet.len();
465 packet.extend_from_slice(&0u16.to_be_bytes()); let value_start = packet.len();
468
469 let magic_cookie = 0x2112A442u32;
471
472 match addr {
473 std::net::SocketAddr::V4(addr_v4) => {
474 packet.push(0x00); packet.push(0x01); let xor_port = addr_v4.port() ^ (magic_cookie >> 16) as u16;
479 packet.extend_from_slice(&xor_port.to_be_bytes());
480
481 let ip_bytes = addr_v4.ip().octets();
483 let magic_bytes = magic_cookie.to_be_bytes();
484 for i in 0..4 {
485 packet.push(ip_bytes[i] ^ magic_bytes[i]);
486 }
487 }
488 std::net::SocketAddr::V6(addr_v6) => {
489 packet.push(0x00); packet.push(0x02); let xor_port = addr_v6.port() ^ (magic_cookie >> 16) as u16;
494 packet.extend_from_slice(&xor_port.to_be_bytes());
495
496 let ip_bytes = addr_v6.ip().octets();
498 let magic_bytes = magic_cookie.to_be_bytes();
499
500 for i in 0..4 {
502 packet.push(ip_bytes[i] ^ magic_bytes[i]);
503 }
504
505 for i in 0..12 {
507 packet.push(ip_bytes[i + 4] ^ transaction_id[i]);
508 }
509 }
510 }
511
512 let value_length = (packet.len() - value_start) as u16;
513 packet[attr_start..attr_start + 2].copy_from_slice(&value_length.to_be_bytes());
514
515 Self::add_padding(packet);
517
518 Ok(())
519 }
520
521 fn add_software_attribute(packet: &mut Vec<u8>, software: &str) -> Result<()> {
523 packet.extend_from_slice(&0x8022u16.to_be_bytes());
525
526 let software_bytes = software.as_bytes();
527 let length = software_bytes.len() as u16;
528
529 packet.extend_from_slice(&length.to_be_bytes());
531
532 packet.extend_from_slice(software_bytes);
534
535 Self::add_padding(packet);
537
538 Ok(())
539 }
540
541 fn add_error_code_attribute(packet: &mut Vec<u8>, error_code: u16, reason: &str) -> Result<()> {
543 packet.extend_from_slice(&0x0009u16.to_be_bytes());
545
546 let attr_start = packet.len();
548 packet.extend_from_slice(&0u16.to_be_bytes()); let value_start = packet.len();
551
552 packet.extend_from_slice(&0u16.to_be_bytes()); let class = (error_code / 100) as u8;
555 let number = (error_code % 100) as u8;
556 packet.push(class);
557 packet.push(number);
558
559 packet.extend_from_slice(reason.as_bytes());
561
562 let value_length = (packet.len() - value_start) as u16;
563 packet[attr_start..attr_start + 2].copy_from_slice(&value_length.to_be_bytes());
564
565 Self::add_padding(packet);
567
568 Ok(())
569 }
570
571 fn add_padding(packet: &mut Vec<u8>) {
573 let remainder = packet.len() % 4;
574 if remainder != 0 {
575 let padding = 4 - remainder;
576 packet.extend_from_slice(&vec![0u8; padding]);
577 }
578 }
579}
580
581fn send_stun_binding_response_action() -> ActionDefinition {
584 ActionDefinition {
585 name: "send_stun_binding_response".to_string(),
586 description: "Send STUN binding response with mapped address".to_string(),
587 parameters: vec![
588 Parameter {
589 name: "mapped_address".to_string(),
590 type_hint: "string".to_string(),
591 description:
592 "Client's public IP:port as seen by server (e.g., \"203.0.113.45:54321\")"
593 .to_string(),
594 required: true,
595 },
596 Parameter {
597 name: "transaction_id".to_string(),
598 type_hint: "string".to_string(),
599 description: "Transaction ID from the request, hex-encoded (exactly 24 hex chars = 12 bytes). MUST be copied from the event's transaction_id: a STUN client silently discards any response whose transaction ID does not match the request it sent.".to_string(),
600 required: true,
601 },
602 Parameter {
603 name: "xor_mapped_address".to_string(),
604 type_hint: "boolean".to_string(),
605 description:
606 "Use XOR-MAPPED-ADDRESS (true) or MAPPED-ADDRESS (false). Default: true"
607 .to_string(),
608 required: false,
609 },
610 Parameter {
611 name: "software".to_string(),
612 type_hint: "string".to_string(),
613 description: "Software version string. Default: \"NetGet/1.0\"".to_string(),
614 required: false,
615 },
616 ],
617 example: json!({
618 "type": "send_stun_binding_response",
619 "mapped_address": "203.0.113.45:54321",
620 "transaction_id": "0123456789abcdef01234567",
621 "xor_mapped_address": true,
622 "software": "NetGet STUN/1.0"
623 }),
624 log_template: Some(
625 LogTemplate::new()
626 .with_info("-> STUN binding response {mapped_address}")
627 .with_debug("STUN binding_response: {mapped_address}"),
628 ),
629 }
630}
631
632fn send_stun_error_response_action() -> ActionDefinition {
633 ActionDefinition {
634 name: "send_stun_error_response".to_string(),
635 description: "Send STUN error response".to_string(),
636 parameters: vec![
637 Parameter {
638 name: "error_code".to_string(),
639 type_hint: "number".to_string(),
640 description: "STUN error code (e.g., 400, 401, 420, 438, 500)".to_string(),
641 required: true,
642 },
643 Parameter {
644 name: "reason".to_string(),
645 type_hint: "string".to_string(),
646 description: "Error reason phrase".to_string(),
647 required: true,
648 },
649 Parameter {
650 name: "transaction_id".to_string(),
651 type_hint: "string".to_string(),
652 description: "Transaction ID from request (hex string)".to_string(),
653 required: true,
654 },
655 ],
656 example: json!({
657 "type": "send_stun_error_response",
658 "error_code": 401,
659 "reason": "Unauthorized",
660 "transaction_id": "0123456789abcdef01234567"
661 }),
662 log_template: Some(
663 LogTemplate::new()
664 .with_info("-> STUN error {error_code}: {reason}")
665 .with_debug("STUN error_response: {error_code} {reason}"),
666 ),
667 }
668}
669
670fn ignore_request_action() -> ActionDefinition {
671 ActionDefinition {
672 name: "ignore_request".to_string(),
673 description: "Silently ignore the STUN request (no response)".to_string(),
674 parameters: vec![],
675 example: json!({
676 "type": "ignore_request"
677 }),
678 log_template: Some(
679 LogTemplate::new()
680 .with_info("STUN request ignored")
681 .with_debug("STUN ignore_request"),
682 ),
683 }
684}
685
686pub static STUN_BINDING_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
689 EventType::new(
690 "stun_binding_request",
691 "STUN binding request received from client",
692 json!({
698 "type": "send_stun_binding_response",
699 "mapped_address": "198.51.100.20:54321",
700 "transaction_id": "0123456789abcdef01234567"
701 }),
702 )
703 .with_parameters(vec![
704 Parameter {
705 name: "peer_addr".to_string(),
706 type_hint: "string".to_string(),
707 description: "Client's IP:port address (public address as seen by server)".to_string(),
708 required: true,
709 },
710 Parameter {
711 name: "local_addr".to_string(),
712 type_hint: "string".to_string(),
713 description: "Server's listening IP:port address".to_string(),
714 required: true,
715 },
716 Parameter {
717 name: "transaction_id".to_string(),
718 type_hint: "string".to_string(),
719 description: "STUN transaction ID (hex-encoded, 12 bytes = 24 hex chars)".to_string(),
720 required: true,
721 },
722 Parameter {
723 name: "message_type".to_string(),
724 type_hint: "string".to_string(),
725 description: "STUN message type. Always \"BindingRequest\": the server drops every \
726 other class and method before raising this event, so nothing else \
727 reaches you here."
728 .to_string(),
729 required: true,
730 },
731 Parameter {
732 name: "bytes_received".to_string(),
733 type_hint: "number".to_string(),
734 description: "Number of bytes in the STUN request".to_string(),
735 required: true,
736 },
737 ])
738 .with_actions(vec![
743 send_stun_binding_response_action(),
744 send_stun_error_response_action(),
745 ignore_request_action(),
746 ])
747 .with_log_template(
748 LogTemplate::new()
749 .with_info("STUN {client_ip} binding request")
750 .with_debug("STUN binding request from {client_ip}:{client_port}")
751 .with_trace("STUN: {json_pretty(.)}"),
752 )
753});
754
755fn get_stun_event_types() -> Vec<EventType> {
756 vec![STUN_BINDING_REQUEST_EVENT.clone()]
757}