Skip to main content

netget/client/igmp/
actions.rs

1//! IGMP 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::log_template::LogTemplate;
9use crate::protocol::EventType;
10use crate::state::app_state::AppState;
11use anyhow::{anyhow, Context, Result};
12use serde_json::json;
13use std::net::Ipv4Addr;
14use std::sync::LazyLock;
15
16/// IGMP client connected event
17pub static IGMP_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
18    EventType::new(
19        "igmp_connected",
20        "IGMP client initialized and ready to join multicast groups",
21        json!({
22            "type": "join_multicast_group",
23            "multicast_addr": "239.1.2.3",
24            "interface_addr": "0.0.0.0"
25        }),
26    )
27    .with_parameters(vec![Parameter {
28        name: "local_addr".to_string(),
29        type_hint: "string".to_string(),
30        description: "Local socket address bound for multicast reception".to_string(),
31        required: true,
32    }])
33});
34
35/// IGMP client data received event
36pub static IGMP_CLIENT_DATA_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
37    EventType::new(
38        "igmp_data_received",
39        "Multicast data received from group",
40        json!({
41            "type": "wait_for_more"
42        }),
43    )
44    .with_parameters(vec![
45        Parameter {
46            name: "data_hex".to_string(),
47            type_hint: "string".to_string(),
48            description: "The multicast data received (as hex string)".to_string(),
49            required: true,
50        },
51        Parameter {
52            name: "data_length".to_string(),
53            type_hint: "number".to_string(),
54            description: "Full length of the datagram in bytes, even when data_hex is truncated"
55                .to_string(),
56            required: true,
57        },
58        Parameter {
59            name: "data_truncated".to_string(),
60            type_hint: "boolean".to_string(),
61            description: "True when data_hex shows only the first 2048 bytes of a larger datagram"
62                .to_string(),
63            required: true,
64        },
65        Parameter {
66            name: "source_addr".to_string(),
67            type_hint: "string".to_string(),
68            description: "Source address of the multicast sender".to_string(),
69            required: true,
70        },
71    ])
72});
73
74/// IGMP client protocol action handler
75pub struct IgmpClientProtocol;
76
77impl IgmpClientProtocol {
78    pub fn new() -> Self {
79        Self
80    }
81}
82
83// Implement Protocol trait (common functionality)
84impl Protocol for IgmpClientProtocol {
85    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
86        vec![
87            ActionDefinition {
88                name: "join_multicast_group".to_string(),
89                description: "Join a multicast group to start receiving multicast traffic"
90                    .to_string(),
91                parameters: vec![
92                    Parameter {
93                        name: "multicast_addr".to_string(),
94                        type_hint: "string".to_string(),
95                        description: "Multicast group IP address (e.g., 239.1.2.3)".to_string(),
96                        required: true,
97                    },
98                    Parameter {
99                        name: "interface_addr".to_string(),
100                        type_hint: "string".to_string(),
101                        description: "Local interface address (use 0.0.0.0 for any interface)"
102                            .to_string(),
103                        required: false,
104                    },
105                ],
106                example: json!({
107                    "type": "join_multicast_group",
108                    "multicast_addr": "239.1.2.3",
109                    "interface_addr": "0.0.0.0"
110                }),
111                log_template: None,
112            },
113            ActionDefinition {
114                name: "leave_multicast_group".to_string(),
115                description: "Leave a multicast group to stop receiving multicast traffic"
116                    .to_string(),
117                parameters: vec![
118                    Parameter {
119                        name: "multicast_addr".to_string(),
120                        type_hint: "string".to_string(),
121                        description: "Multicast group IP address to leave".to_string(),
122                        required: true,
123                    },
124                    Parameter {
125                        name: "interface_addr".to_string(),
126                        type_hint: "string".to_string(),
127                        description: "Local interface address (use 0.0.0.0 for any interface)"
128                            .to_string(),
129                        required: false,
130                    },
131                ],
132                example: json!({
133                    "type": "leave_multicast_group",
134                    "multicast_addr": "239.1.2.3",
135                    "interface_addr": "0.0.0.0"
136                }),
137                log_template: None,
138            },
139            ActionDefinition {
140                name: "send_multicast".to_string(),
141                description: "Send data to a multicast group".to_string(),
142                parameters: vec![
143                    Parameter {
144                        name: "multicast_addr".to_string(),
145                        type_hint: "string".to_string(),
146                        description: "Multicast group IP address".to_string(),
147                        required: true,
148                    },
149                    Parameter {
150                        name: "port".to_string(),
151                        type_hint: "number".to_string(),
152                        description: "Destination port".to_string(),
153                        required: true,
154                    },
155                    Parameter {
156                        name: "data_hex".to_string(),
157                        type_hint: "string".to_string(),
158                        description: "Hexadecimal encoded data to send".to_string(),
159                        required: true,
160                    },
161                ],
162                example: json!({
163                    "type": "send_multicast",
164                    "multicast_addr": "239.1.2.3",
165                    "port": 5000,
166                    "data_hex": "48656c6c6f"
167                }),
168                log_template: None,
169            },
170        ]
171    }
172    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
173        vec![ActionDefinition {
174            name: "wait_for_more".to_string(),
175            description: "Wait for more multicast data before responding".to_string(),
176            parameters: vec![],
177            example: json!({
178                "type": "wait_for_more"
179            }),
180            log_template: Some(
181                LogTemplate::new()
182                    .with_info("-> IGMP wait for more")
183                    .with_debug("IGMP wait_for_more"),
184            ),
185        }]
186    }
187    fn get_event_types(&self) -> Vec<EventType> {
188        vec![
189            IGMP_CLIENT_CONNECTED_EVENT.clone(),
190            IGMP_CLIENT_DATA_RECEIVED_EVENT.clone(),
191        ]
192    }
193    fn protocol_name(&self) -> &'static str {
194        "igmp"
195    }
196    fn stack_name(&self) -> &'static str {
197        "Network"
198    }
199    fn keywords(&self) -> Vec<&'static str> {
200        vec!["igmp", "multicast", "igmp client", "multicast client"]
201    }
202    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
203        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
204
205        ProtocolMetadataV2::builder()
206            .state(DevelopmentState::Experimental)
207            .implementation("socket2 for multicast join/leave, tokio UdpSocket for data")
208            .llm_control("Join/leave multicast groups, send/receive multicast data")
209            .e2e_testing("UDP sender/receiver for multicast packets")
210            .notes("IPv4 multicast only, uses socket options (no raw IGMP)")
211            .build()
212    }
213    fn description(&self) -> &'static str {
214        "IGMP client for joining/leaving multicast groups and receiving/sending multicast data"
215    }
216    fn example_prompt(&self) -> &'static str {
217        "Join multicast group 239.1.2.3 on port 5000, log all received data, and send 'Hello' to the group"
218    }
219    fn group_name(&self) -> &'static str {
220        "Network"
221    }
222    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
223        use crate::llm::actions::StartupExamples;
224        use serde_json::json;
225
226        StartupExamples::new(
227            // LLM mode: LLM controls multicast operations
228            json!({
229                "type": "open_client",
230                "remote_addr": "0.0.0.0:5000",
231                "base_stack": "igmp",
232                "instruction": "Join multicast group 239.1.2.3 and log received data"
233            }),
234            // Script mode: Code-based multicast handling
235            json!({
236                "type": "open_client",
237                "remote_addr": "0.0.0.0:5000",
238                "base_stack": "igmp",
239                "event_handlers": [{
240                    "event_pattern": "igmp_data_received",
241                    "handler": {
242                        "type": "script",
243                        "language": "python",
244                        "code": "<igmp_client_handler>"
245                    }
246                }]
247            }),
248            // Static mode: Fixed multicast group join
249            json!({
250                "type": "open_client",
251                "remote_addr": "0.0.0.0:5000",
252                "base_stack": "igmp",
253                "event_handlers": [
254                    {
255                        "event_pattern": "igmp_connected",
256                        "handler": {
257                            "type": "static",
258                            "actions": [{
259                                "type": "join_multicast_group",
260                                "multicast_addr": "239.1.2.3",
261                                "interface_addr": "0.0.0.0"
262                            }]
263                        }
264                    }
265                ]
266            }),
267        )
268    }
269}
270
271// Implement Client trait (client-specific functionality)
272impl Client for IgmpClientProtocol {
273    fn connect(
274        &self,
275        ctx: crate::protocol::ConnectContext,
276    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
277    {
278        Box::pin(async move {
279            use crate::client::igmp::IgmpClient;
280            IgmpClient::connect_with_llm_actions(
281                ctx.remote_addr,
282                ctx.llm_client,
283                ctx.state,
284                ctx.status_tx,
285                ctx.client_id,
286            )
287            .await
288        })
289    }
290    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
291        let action_type = action["type"]
292            .as_str()
293            .ok_or_else(|| anyhow!("Missing 'type' field in action"))?;
294
295        match action_type {
296            "join_multicast_group" => {
297                let multicast_addr = action["multicast_addr"]
298                    .as_str()
299                    .ok_or_else(|| anyhow!("Missing multicast_addr"))?;
300
301                let interface_addr = action["interface_addr"].as_str().unwrap_or("0.0.0.0");
302
303                // Parse addresses
304                let multicast_ip: Ipv4Addr = multicast_addr
305                    .parse()
306                    .context("Invalid multicast address")?;
307
308                let interface_ip: Ipv4Addr = interface_addr
309                    .parse()
310                    .context("Invalid interface address")?;
311
312                Ok(ClientActionResult::Custom {
313                    name: "join_multicast_group".to_string(),
314                    data: json!({
315                        "multicast_addr": multicast_ip.to_string(),
316                        "interface_addr": interface_ip.to_string(),
317                    }),
318                })
319            }
320            "leave_multicast_group" => {
321                let multicast_addr = action["multicast_addr"]
322                    .as_str()
323                    .ok_or_else(|| anyhow!("Missing multicast_addr"))?;
324
325                let interface_addr = action["interface_addr"].as_str().unwrap_or("0.0.0.0");
326
327                // Parse addresses
328                let multicast_ip: Ipv4Addr = multicast_addr
329                    .parse()
330                    .context("Invalid multicast address")?;
331
332                let interface_ip: Ipv4Addr = interface_addr
333                    .parse()
334                    .context("Invalid interface address")?;
335
336                Ok(ClientActionResult::Custom {
337                    name: "leave_multicast_group".to_string(),
338                    data: json!({
339                        "multicast_addr": multicast_ip.to_string(),
340                        "interface_addr": interface_ip.to_string(),
341                    }),
342                })
343            }
344            "send_multicast" => {
345                let multicast_addr = action["multicast_addr"]
346                    .as_str()
347                    .ok_or_else(|| anyhow!("Missing multicast_addr"))?;
348
349                // Validated to a real IPv4 address here rather than at send time: the send
350                // path builds "addr:port" and parses it, so an unusable value would surface
351                // as a parse failure naming a synthesised string instead of the field the
352                // model actually got wrong.
353                let multicast_ip: Ipv4Addr = multicast_addr
354                    .parse()
355                    .context("Invalid multicast address")?;
356
357                let port = action["port"]
358                    .as_u64()
359                    .ok_or_else(|| anyhow!("Missing or invalid port"))?;
360                let port = u16::try_from(port)
361                    .map_err(|_| anyhow!("port {port} is outside the 0-65535 range"))?;
362
363                let data_hex = action["data_hex"]
364                    .as_str()
365                    .ok_or_else(|| anyhow!("Missing data_hex"))?;
366
367                // Decode hex data. The parameter is documented as hex, so it is decoded here;
368                // the bytes on the wire are never the ASCII of the hex string.
369                let data = hex::decode(data_hex).context("Invalid hex data")?;
370
371                Ok(ClientActionResult::Custom {
372                    name: "send_multicast".to_string(),
373                    data: json!({
374                        "multicast_addr": multicast_ip.to_string(),
375                        "port": port,
376                        "data": data,
377                    }),
378                })
379            }
380            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
381            _ => Err(anyhow!("Unknown action type: {}", action_type)),
382        }
383    }
384}