Skip to main content

netget/client/turn/
actions.rs

1//! TURN client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// TURN client connected event
15pub static TURN_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "turn_connected",
18        "TURN client successfully connected to server",
19        json!({
20            "type": "allocate_turn_relay",
21            "lifetime_seconds": 600
22        }),
23    )
24    .with_parameters(vec![Parameter {
25        name: "remote_addr".to_string(),
26        type_hint: "string".to_string(),
27        description: "TURN server address".to_string(),
28        required: true,
29    }])
30});
31
32/// TURN client allocation success event
33pub static TURN_CLIENT_ALLOCATED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
34    EventType::new(
35        "turn_allocated",
36        "TURN relay address allocated successfully",
37        json!({
38            "type": "create_permission",
39            "peer_address": "192.168.1.100:5000"
40        }),
41    )
42    .with_parameters(vec![
43        Parameter {
44            name: "relay_address".to_string(),
45            type_hint: "string".to_string(),
46            description: "The allocated relay address (IP:port)".to_string(),
47            required: true,
48        },
49        Parameter {
50            name: "lifetime_seconds".to_string(),
51            type_hint: "number".to_string(),
52            description: "Allocation lifetime in seconds".to_string(),
53            required: true,
54        },
55        Parameter {
56            name: "transaction_id".to_string(),
57            type_hint: "string".to_string(),
58            description: "Transaction ID (hex)".to_string(),
59            required: true,
60        },
61    ])
62});
63
64/// TURN client data received event
65pub static TURN_CLIENT_DATA_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
66    EventType::new(
67        "turn_data_received",
68        "Data received from peer via TURN relay",
69        json!({
70            "type": "send_turn_data",
71            "peer_address": "192.168.1.100:5000",
72            "data_hex": "48656c6c6f"
73        }),
74    )
75    .with_parameters(vec![
76        Parameter {
77            name: "peer_address".to_string(),
78            type_hint: "string".to_string(),
79            description: "Peer address that sent the data".to_string(),
80            required: true,
81        },
82        Parameter {
83            name: "data_hex".to_string(),
84            type_hint: "string".to_string(),
85            description: "The data received (as hex string)".to_string(),
86            required: true,
87        },
88        Parameter {
89            name: "data_length".to_string(),
90            type_hint: "number".to_string(),
91            description: "Length of data in bytes".to_string(),
92            required: true,
93        },
94    ])
95});
96
97/// TURN client permission created event
98pub static TURN_CLIENT_PERMISSION_CREATED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
99    EventType::new(
100        "turn_permission_created",
101        "Permission created for peer address",
102        json!({
103            "type": "wait_for_more"
104        }),
105    )
106    .with_parameters(vec![
107        Parameter {
108            name: "peer_address".to_string(),
109            type_hint: "string".to_string(),
110            description: "Peer address granted permission. Recovered by correlating the \
111                          response's transaction ID with the CreatePermission request that \
112                          named this peer — RFC 8656 section 9.4 makes the Success Response \
113                          empty, so the server does not repeat it. A response whose \
114                          transaction ID matches nothing this client sent raises no event at \
115                          all rather than reporting a peer it cannot name."
116                .to_string(),
117            required: true,
118        },
119        Parameter {
120            name: "transaction_id".to_string(),
121            type_hint: "string".to_string(),
122            description: "Transaction ID of the CreatePermission exchange (hex)".to_string(),
123            required: true,
124        },
125    ])
126});
127
128/// TURN client allocation refreshed event
129pub static TURN_CLIENT_REFRESHED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
130    EventType::new(
131        "turn_refreshed",
132        "TURN allocation lifetime extended",
133        // Not `placeholder`: the example is what the model copies, and no executor
134        // accepts an action by that name.
135        json!({
136            "type": "refresh_allocation",
137            "lifetime_seconds": 600
138        }),
139    )
140    .with_parameters(vec![Parameter {
141        name: "lifetime_seconds".to_string(),
142        type_hint: "number".to_string(),
143        description: "New lifetime in seconds".to_string(),
144        required: true,
145    }])
146});
147
148/// TURN client protocol action handler
149pub struct TurnClientProtocol;
150
151impl TurnClientProtocol {
152    pub fn new() -> Self {
153        Self
154    }
155}
156
157// Implement Protocol trait (common functionality)
158impl Protocol for TurnClientProtocol {
159    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
160        vec![
161            ActionDefinition {
162                name: "allocate_turn_relay".to_string(),
163                description: "Request a relay address allocation from TURN server".to_string(),
164                parameters: vec![Parameter {
165                    name: "lifetime_seconds".to_string(),
166                    type_hint: "number".to_string(),
167                    description: "Requested lifetime in seconds (default: 600)".to_string(),
168                    required: false,
169                }],
170                example: json!({
171                    "type": "allocate_turn_relay",
172                    "lifetime_seconds": 600
173                }),
174                log_template: None,
175            },
176            ActionDefinition {
177                name: "create_permission".to_string(),
178                description: "Create permission for a peer address to send/receive data"
179                    .to_string(),
180                parameters: vec![Parameter {
181                    name: "peer_address".to_string(),
182                    type_hint: "string".to_string(),
183                    description: "Peer IP:port to grant permission (e.g., '192.168.1.100:5000')"
184                        .to_string(),
185                    required: true,
186                }],
187                example: json!({
188                    "type": "create_permission",
189                    "peer_address": "192.168.1.100:5000"
190                }),
191                log_template: None,
192            },
193            ActionDefinition {
194                name: "send_turn_data".to_string(),
195                description: "Send data to peer via TURN relay".to_string(),
196                parameters: vec![
197                    Parameter {
198                        name: "peer_address".to_string(),
199                        type_hint: "string".to_string(),
200                        description: "Peer IP:port to send data to".to_string(),
201                        required: true,
202                    },
203                    Parameter {
204                        name: "data_hex".to_string(),
205                        type_hint: "string".to_string(),
206                        description: "Data to send (as hex string)".to_string(),
207                        required: true,
208                    },
209                ],
210                example: json!({
211                    "type": "send_turn_data",
212                    "peer_address": "192.168.1.100:5000",
213                    "data_hex": "48656c6c6f"
214                }),
215                log_template: None,
216            },
217            ActionDefinition {
218                name: "refresh_allocation".to_string(),
219                description: "Refresh TURN allocation to extend lifetime".to_string(),
220                parameters: vec![Parameter {
221                    name: "lifetime_seconds".to_string(),
222                    type_hint: "number".to_string(),
223                    description: "New lifetime in seconds (0 to delete allocation)".to_string(),
224                    required: false,
225                }],
226                example: json!({
227                    "type": "refresh_allocation",
228                    "lifetime_seconds": 600
229                }),
230                log_template: None,
231            },
232            ActionDefinition {
233                name: "disconnect".to_string(),
234                description: "Disconnect from TURN server".to_string(),
235                parameters: vec![],
236                example: json!({
237                    "type": "disconnect"
238                }),
239                log_template: None,
240            },
241        ]
242    }
243    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
244        vec![
245            ActionDefinition {
246                name: "send_turn_data".to_string(),
247                description: "Send data to peer via TURN relay in response to received data"
248                    .to_string(),
249                parameters: vec![
250                    Parameter {
251                        name: "peer_address".to_string(),
252                        type_hint: "string".to_string(),
253                        description: "Peer IP:port to send data to".to_string(),
254                        required: true,
255                    },
256                    Parameter {
257                        name: "data_hex".to_string(),
258                        type_hint: "string".to_string(),
259                        description: "Data to send (as hex string)".to_string(),
260                        required: true,
261                    },
262                ],
263                example: json!({
264                    "type": "send_turn_data",
265                    "peer_address": "192.168.1.100:5000",
266                    "data_hex": "48656c6c6f"
267                }),
268                log_template: None,
269            },
270            ActionDefinition {
271                name: "wait_for_more".to_string(),
272                description: "Wait for more data before responding".to_string(),
273                parameters: vec![],
274                example: json!({
275                    "type": "wait_for_more"
276                }),
277                log_template: None,
278            },
279        ]
280    }
281    fn protocol_name(&self) -> &'static str {
282        "TURN"
283    }
284    /// The events this client raises — **the same `LazyLock` statics it actually
285    /// emits**, not fresh copies.
286    ///
287    /// This used to build five brand-new `EventType`s whose `example` was
288    /// `{"type": "placeholder", "event_id": …}` and which carried no parameters at
289    /// all. `placeholder` is not an action any executor accepts, so the one
290    /// worked example an operator or model sees for each event was unusable, and
291    /// the parameter list the statics do carry was invisible here. Two
292    /// descriptions of the same event that can drift are one too many; there is
293    /// now a single source.
294    fn get_event_types(&self) -> Vec<EventType> {
295        vec![
296            TURN_CLIENT_CONNECTED_EVENT.clone(),
297            TURN_CLIENT_ALLOCATED_EVENT.clone(),
298            TURN_CLIENT_DATA_RECEIVED_EVENT.clone(),
299            TURN_CLIENT_PERMISSION_CREATED_EVENT.clone(),
300            TURN_CLIENT_REFRESHED_EVENT.clone(),
301        ]
302    }
303    fn stack_name(&self) -> &'static str {
304        "ETH>IP>UDP>STUN/TURN"
305    }
306    fn keywords(&self) -> Vec<&'static str> {
307        vec!["turn", "turn client", "relay", "nat traversal", "webrtc"]
308    }
309    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
310        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
311
312        ProtocolMetadataV2::builder()
313            .state(DevelopmentState::Experimental)
314            // NOT webrtc-turn, which this claimed for a long time. That crate is an
315            // optional dependency of the `turn` feature and nothing in src/ calls
316            // it; every byte here is hand-built on the STUN message format.
317            .implementation(
318                "Manual TURN client (RFC 8656) on the STUN message format — Allocate, \
319                 Refresh, CreatePermission and Send indications are built and parsed here, \
320                 not by a library",
321            )
322            .llm_control("Full control over allocations, permissions, and relay data")
323            .e2e_testing(
324                "NetGet TURN server as test server; tests/client/turn/command_channel_test.rs \
325                 asserts the datagrams reach a real socket and \
326                 tests/client/turn/response_parsing_test.rs asserts an Allocate Success \
327                 Response is decoded and that malformed ones do not kill the read loop",
328            )
329            .notes(
330                "remote_addr must be a literal IP:port — it is parsed with \
331                 `SocketAddr::parse` and hostnames are rejected. No authentication: \
332                 MESSAGE-INTEGRITY, USERNAME, REALM and NONCE are not implemented, so this \
333                 cannot talk to a public TURN service, only to one that grants without \
334                 credentials. Responses are correlated by transaction ID only for \
335                 CreatePermission; an Allocate or Refresh response from any source that can \
336                 reach the client's ephemeral port is accepted, so do not point this at an \
337                 untrusted network. UDP allocations only: no TCP, no TLS, no ChannelBind, and \
338                 no automatic refresh — the model must refresh before the lifetime expires.",
339            )
340            .build()
341    }
342    fn description(&self) -> &'static str {
343        "TURN client for NAT traversal relay"
344    }
345    fn example_prompt(&self) -> &'static str {
346        "Connect to TURN server at 127.0.0.1:3478 and allocate a relay address"
347    }
348    fn group_name(&self) -> &'static str {
349        "Network Infrastructure"
350    }
351    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
352        use crate::llm::actions::StartupExamples;
353        use serde_json::json;
354
355        StartupExamples::new(
356            // LLM mode: LLM controls TURN relay
357            json!({
358                "type": "open_client",
359                "remote_addr": "127.0.0.1:3478",
360                "base_stack": "turn",
361                "instruction": "Allocate a relay address and create permission for peer 192.168.1.100:5000"
362            }),
363            // Script mode: Code-based TURN handling
364            json!({
365                "type": "open_client",
366                "remote_addr": "127.0.0.1:3478",
367                "base_stack": "turn",
368                "event_handlers": [{
369                    "event_pattern": "turn_data_received",
370                    "handler": {
371                        "type": "script",
372                        "language": "python",
373                        "code": "<turn_client_handler>"
374                    }
375                }]
376            }),
377            // Static mode: Fixed relay allocation
378            json!({
379                "type": "open_client",
380                "remote_addr": "127.0.0.1:3478",
381                "base_stack": "turn",
382                "event_handlers": [
383                    {
384                        "event_pattern": "turn_connected",
385                        "handler": {
386                            "type": "static",
387                            "actions": [{
388                                "type": "allocate_turn_relay",
389                                "lifetime_seconds": 600
390                            }]
391                        }
392                    },
393                    {
394                        "event_pattern": "turn_allocated",
395                        "handler": {
396                            "type": "static",
397                            "actions": [{
398                                "type": "create_permission",
399                                "peer_address": "192.168.1.100:5000"
400                            }]
401                        }
402                    }
403                ]
404            }),
405        )
406    }
407}
408
409// Implement Client trait (client-specific functionality)
410impl Client for TurnClientProtocol {
411    fn connect(
412        &self,
413        ctx: crate::protocol::ConnectContext,
414    ) -> std::pin::Pin<
415        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
416    > {
417        Box::pin(async move {
418            use crate::client::turn::TurnClient;
419            TurnClient::connect_with_llm_actions(
420                ctx.remote_addr,
421                ctx.llm_client,
422                ctx.state,
423                ctx.status_tx,
424                ctx.client_id,
425            )
426            .await
427        })
428    }
429    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
430        let action_type = action
431            .get("type")
432            .and_then(|v| v.as_str())
433            .context("Missing 'type' field in action")?;
434
435        match action_type {
436            "allocate_turn_relay" => {
437                let lifetime = action
438                    .get("lifetime_seconds")
439                    .and_then(|v| v.as_u64())
440                    .unwrap_or(600);
441
442                Ok(ClientActionResult::Custom {
443                    name: "allocate".to_string(),
444                    data: json!({
445                        "lifetime_seconds": lifetime
446                    }),
447                })
448            }
449            "create_permission" => {
450                let peer_address = action
451                    .get("peer_address")
452                    .and_then(|v| v.as_str())
453                    .context("Missing 'peer_address' field")?;
454
455                Ok(ClientActionResult::Custom {
456                    name: "create_permission".to_string(),
457                    data: json!({
458                        "peer_address": peer_address
459                    }),
460                })
461            }
462            "send_turn_data" => {
463                let peer_address = action
464                    .get("peer_address")
465                    .and_then(|v| v.as_str())
466                    .context("Missing 'peer_address' field")?;
467
468                let data_hex = action
469                    .get("data_hex")
470                    .and_then(|v| v.as_str())
471                    .context("Missing 'data_hex' field")?;
472
473                let data = hex::decode(data_hex).context("Invalid hex data")?;
474
475                Ok(ClientActionResult::Custom {
476                    name: "send_indication".to_string(),
477                    data: json!({
478                        "peer_address": peer_address,
479                        "data": data
480                    }),
481                })
482            }
483            "refresh_allocation" => {
484                let lifetime = action
485                    .get("lifetime_seconds")
486                    .and_then(|v| v.as_u64())
487                    .unwrap_or(600);
488
489                Ok(ClientActionResult::Custom {
490                    name: "refresh".to_string(),
491                    data: json!({
492                        "lifetime_seconds": lifetime
493                    }),
494                })
495            }
496            "disconnect" => Ok(ClientActionResult::Disconnect),
497            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
498            _ => Err(anyhow::anyhow!(
499                "Unknown TURN client action: {}",
500                action_type
501            )),
502        }
503    }
504}