Skip to main content

netget/server/stun/
actions.rs

1//! STUN protocol actions implementation
2
3use 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() // STUN server is purely reactive
25    }
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, with the real Binding Success Response. A STUN Binding response
57            // asserts one thing - the reflexive transport address - and the server observed that
58            // itself from the datagram; the model never supplies it on this path. So the static
59            // answer is true whether or not the model was reachable, and it is logged
60            // decision=static_fallback_llm_error, deliberately not fail_closed_*.
61            .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        // Deterministic: reflect the client's mapped address back for every
126        // binding request, echoing its transaction id, no LLM call.
127        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            // LLM mode: LLM handles all STUN responses
141            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            // Script mode: Code-based deterministic responses
148            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            // Static mode: no LLM call, but still echoes the client's own
162            // transaction ID and address by interpolating the event fields. A
163            // hardcoded transaction ID would never match the client's request,
164            // and every STUN client discards a response whose transaction ID
165            // differs from the one it sent.
166            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    /// Execute STUN binding response action
224    fn execute_send_binding_response(&self, action: serde_json::Value) -> Result<ActionResult> {
225        // Extract parameters
226        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        // Optional parameters
237        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        // Parse transaction ID from hex
248        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        // Parse mapped address
256        let addr: std::net::SocketAddr = mapped_address
257            .parse()
258            .context("Invalid mapped_address format")?;
259
260        // Build STUN binding response
261        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    /// Execute STUN error response action
272    fn execute_send_error_response(&self, action: serde_json::Value) -> Result<ActionResult> {
273        // Both are declared `required: true`, so the executor enforces it rather
274        // than substituting a default. Silently answering 400 Bad Request when
275        // the model asked for 401 Unauthorized would put a different decision on
276        // the wire from the one it made, and hide the malformed action.
277        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        // Range-check *before* narrowing. `65836 as u16` is 300, which passes the check below
283        // and is then indistinguishable from a model that legitimately asked for 300 - the
284        // wrap has to be caught while the original value is still visible.
285        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        // Parse transaction ID from hex
311        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        // Build STUN error response
319        let packet = Self::build_error_response(&transaction_id_bytes, error_code, reason)?;
320
321        Ok(ActionResult::Output(packet))
322    }
323
324    /// Build STUN binding response packet
325    ///
326    /// There is deliberately no MESSAGE-INTEGRITY here, and no parameter asking
327    /// for one. The action used to advertise `message_integrity` and the executor
328    /// took the boolean and threw it away, so a model that set it got exactly the
329    /// unauthenticated response it got with the flag off, while the parameter's
330    /// own description said "Include MESSAGE-INTEGRITY attribute". RFC 8489
331    /// section 14.6 computes that attribute over a key derived from a
332    /// username/realm/password the server does not have and has no way to obtain,
333    /// so the knob could never have been honoured. Removing it is the honest fix.
334    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        // Message Type: 0x0101 (Binding Success Response)
343        packet.extend_from_slice(&0x0101u16.to_be_bytes());
344
345        // Message Length (will be updated later)
346        let length_pos = packet.len();
347        packet.extend_from_slice(&0u16.to_be_bytes());
348
349        // Magic Cookie: 0x2112A442
350        packet.extend_from_slice(&0x2112A442u32.to_be_bytes());
351
352        // Transaction ID (12 bytes)
353        packet.extend_from_slice(transaction_id);
354
355        let attributes_start = packet.len();
356
357        // Add MAPPED-ADDRESS or XOR-MAPPED-ADDRESS attribute
358        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        // Add SOFTWARE attribute
365        Self::add_software_attribute(&mut packet, software)?;
366
367        // Update message length (attributes length, excluding 20-byte header)
368        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    /// Build STUN error response packet (RFC 8489 §6.3.4).
375    ///
376    /// Reached only through `send_stun_error_response`, i.e. when a handler or
377    /// the model deliberately refuses a request. The server's *own* LLM-failure
378    /// path does not come here: it falls back to the mechanical Binding Success
379    /// Response, because for STUN the correct answer is a fact about the
380    /// requester's source address rather than anything the backend contributes.
381    /// This doc comment used to claim the opposite, and the `pub(crate)` it
382    /// justified is what is left of that.
383    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        // Message Type: 0x0111 (Binding Error Response)
391        packet.extend_from_slice(&0x0111u16.to_be_bytes());
392
393        // Message Length (will be updated later)
394        let length_pos = packet.len();
395        packet.extend_from_slice(&0u16.to_be_bytes());
396
397        // Magic Cookie: 0x2112A442
398        packet.extend_from_slice(&0x2112A442u32.to_be_bytes());
399
400        // Transaction ID (12 bytes)
401        packet.extend_from_slice(transaction_id);
402
403        let attributes_start = packet.len();
404
405        // Add ERROR-CODE attribute
406        Self::add_error_code_attribute(&mut packet, error_code, reason)?;
407
408        // Update message length
409        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    /// Add MAPPED-ADDRESS attribute
416    fn add_mapped_address_attribute(
417        packet: &mut Vec<u8>,
418        addr: std::net::SocketAddr,
419    ) -> Result<()> {
420        // Attribute Type: 0x0001 (MAPPED-ADDRESS)
421        packet.extend_from_slice(&0x0001u16.to_be_bytes());
422
423        // Attribute Length
424        let attr_start = packet.len();
425        packet.extend_from_slice(&0u16.to_be_bytes()); // Placeholder
426
427        let value_start = packet.len();
428
429        // Reserved byte + family
430        match addr {
431            std::net::SocketAddr::V4(addr_v4) => {
432                packet.push(0x00); // Reserved
433                packet.push(0x01); // IPv4
434                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); // Reserved
439                packet.push(0x02); // IPv6
440                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        // Add padding to align to 4-byte boundary
449        Self::add_padding(packet);
450
451        Ok(())
452    }
453
454    /// Add XOR-MAPPED-ADDRESS attribute
455    fn add_xor_mapped_address_attribute(
456        packet: &mut Vec<u8>,
457        addr: std::net::SocketAddr,
458        transaction_id: &[u8],
459    ) -> Result<()> {
460        // Attribute Type: 0x0020 (XOR-MAPPED-ADDRESS)
461        packet.extend_from_slice(&0x0020u16.to_be_bytes());
462
463        // Attribute Length
464        let attr_start = packet.len();
465        packet.extend_from_slice(&0u16.to_be_bytes()); // Placeholder
466
467        let value_start = packet.len();
468
469        // Magic cookie for XOR operations
470        let magic_cookie = 0x2112A442u32;
471
472        match addr {
473            std::net::SocketAddr::V4(addr_v4) => {
474                packet.push(0x00); // Reserved
475                packet.push(0x01); // IPv4
476
477                // XOR port with upper 16 bits of magic cookie
478                let xor_port = addr_v4.port() ^ (magic_cookie >> 16) as u16;
479                packet.extend_from_slice(&xor_port.to_be_bytes());
480
481                // XOR address with magic cookie
482                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); // Reserved
490                packet.push(0x02); // IPv6
491
492                // XOR port with upper 16 bits of magic cookie
493                let xor_port = addr_v6.port() ^ (magic_cookie >> 16) as u16;
494                packet.extend_from_slice(&xor_port.to_be_bytes());
495
496                // XOR address with magic cookie + transaction ID
497                let ip_bytes = addr_v6.ip().octets();
498                let magic_bytes = magic_cookie.to_be_bytes();
499
500                // First 4 bytes XORed with magic cookie
501                for i in 0..4 {
502                    packet.push(ip_bytes[i] ^ magic_bytes[i]);
503                }
504
505                // Remaining 12 bytes XORed with transaction ID
506                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        // Add padding to align to 4-byte boundary
516        Self::add_padding(packet);
517
518        Ok(())
519    }
520
521    /// Add SOFTWARE attribute
522    fn add_software_attribute(packet: &mut Vec<u8>, software: &str) -> Result<()> {
523        // Attribute Type: 0x8022 (SOFTWARE)
524        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        // Attribute Length
530        packet.extend_from_slice(&length.to_be_bytes());
531
532        // Attribute Value
533        packet.extend_from_slice(software_bytes);
534
535        // Add padding to align to 4-byte boundary
536        Self::add_padding(packet);
537
538        Ok(())
539    }
540
541    /// Add ERROR-CODE attribute
542    fn add_error_code_attribute(packet: &mut Vec<u8>, error_code: u16, reason: &str) -> Result<()> {
543        // Attribute Type: 0x0009 (ERROR-CODE)
544        packet.extend_from_slice(&0x0009u16.to_be_bytes());
545
546        // Attribute Length
547        let attr_start = packet.len();
548        packet.extend_from_slice(&0u16.to_be_bytes()); // Placeholder
549
550        let value_start = packet.len();
551
552        // Reserved (2 bytes) + Class (1 byte) + Number (1 byte)
553        packet.extend_from_slice(&0u16.to_be_bytes()); // Reserved
554        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        // Reason phrase
560        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        // Add padding to align to 4-byte boundary
566        Self::add_padding(packet);
567
568        Ok(())
569    }
570
571    /// Add padding to align to 4-byte boundary
572    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
581// Action definitions
582
583fn 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
686// Event types
687
688pub static STUN_BINDING_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
689    EventType::new(
690        "stun_binding_request",
691        "STUN binding request received from client",
692        // Concrete values, not "{{event.…}}" placeholders. This is the template for the
693        // *model's own reply*, and nothing substitutes into that — the braces only mean
694        // something in a static event handler. A model copying the placeholder puts the
695        // literal string on the wire and the executor refuses it. Both values must come from
696        // the event; the transaction ID is 12 bytes, hex-encoded.
697        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    // Without this the event advertised no actions at all, so every action the
739    // model produced was rejected as "Unknown Action" and the request went
740    // unanswered. Only static/script handlers, which skip that validation, could
741    // reply.
742    .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}