Skip to main content

netget/client/icmp/
actions.rs

1//! ICMP client protocol actions implementation
2
3use crate::llm::actions::client_trait::{Client, ClientActionResult};
4use crate::llm::actions::protocol_trait::Protocol;
5use crate::llm::actions::{ActionDefinition, Parameter};
6use crate::protocol::EventType;
7use crate::state::app_state::AppState;
8use anyhow::{Context, Result};
9use serde_json::json;
10use std::sync::LazyLock;
11
12/// ICMP client protocol handler
13pub struct IcmpClientProtocol;
14
15impl IcmpClientProtocol {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21// Implement Protocol trait (common functionality)
22impl Protocol for IcmpClientProtocol {
23    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
24        vec![]
25    }
26
27    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
28        vec![
29            send_echo_request_action(),
30            // send_timestamp_request_action(), // TODO: Removed - timestamp support requires pnet timestamp packet types
31            wait_for_more_action(),
32            disconnect_action(),
33        ]
34    }
35
36    fn protocol_name(&self) -> &'static str {
37        "ICMP"
38    }
39
40    fn get_event_types(&self) -> Vec<EventType> {
41        get_icmp_client_event_types()
42    }
43
44    fn stack_name(&self) -> &'static str {
45        "IP>ICMP"
46    }
47
48    fn keywords(&self) -> Vec<&'static str> {
49        vec!["icmp", "icmp client", "ping", "traceroute"]
50    }
51
52    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
53        use crate::protocol::metadata::{
54            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
55        };
56
57        ProtocolMetadataV2::builder()
58            .state(DevelopmentState::Experimental)
59            .privilege_requirement(PrivilegeRequirement::RawSockets)
60            .connectionless()
61            .implementation("Raw IP sockets with pnet_packet for ICMP")
62            .llm_control("Full control over ICMP message types, TTL, and payloads")
63            .e2e_testing(
64                "tests/client/icmp/command_channel_test.rs runs unprivileged and asserts that \
65                 connect() fails, leaves no command handle and makes a later send fail fast; \
66                 tests/client/icmp/action_codec_test.rs asserts the echo request's bytes \
67                 field-by-field against RFC 791/792 with both checksums verified, that the \
68                 executor accepts each advertised example, and that malformed input is refused \
69                 rather than silently defaulted. Both stop at the socket. e2e_test.rs's two \
70                 ping/traceroute tests are #[ignore]d stubs that assert nothing. No test has \
71                 ever sent an ICMP packet.",
72            )
73            .notes(
74                "Needs root/CAP_NET_RAW: connect() opens SOCK_RAW/IPPROTO_ICMP and returns Err \
75                 without it. UNVERIFIED on the wire - no echo request from this client has been \
76                 observed leaving a socket, and until this pass none could have been right: the \
77                 socket did not set IP_HDRINCL while build_echo_request emits a complete IPv4 \
78                 packet, so the kernel prepended a second header and the target saw ICMP type \
79                 0x45. The advertised 'ttl' parameter depended on the same option and was \
80                 equally inert, which is why traceroute could not have worked. An earlier \
81                 version of this field claimed 'real ICMP pings to public IPs' as evidence; no \
82                 such test exists and the repo forbids contacting external endpoints.",
83            )
84            .build()
85    }
86
87    fn description(&self) -> &'static str {
88        "ICMP client for sending echo requests (ping) and traceroute"
89    }
90
91    fn example_prompt(&self) -> &'static str {
92        "Ping 8.8.8.8 every 5 seconds and report RTT"
93    }
94
95    fn group_name(&self) -> &'static str {
96        "Core"
97    }
98
99    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
100        use crate::llm::actions::StartupExamples;
101        use serde_json::json;
102
103        StartupExamples::new(
104            // LLM mode: LLM handles ICMP client
105            json!({
106                "type": "open_client",
107                "remote_addr": "8.8.8.8",
108                "base_stack": "icmp",
109                "instruction": "Ping 8.8.8.8 five times and report average latency"
110            }),
111            // Script mode: Code-based ICMP handling
112            json!({
113                "type": "open_client",
114                "remote_addr": "8.8.8.8",
115                "base_stack": "icmp",
116                "event_handlers": [{
117                    "event_pattern": "icmp_echo_reply",
118                    "handler": {
119                        "type": "script",
120                        "language": "python",
121                        "code": "<icmp_handler>"
122                    }
123                }]
124            }),
125            // Static mode: Fixed ICMP ping
126            json!({
127                "type": "open_client",
128                "remote_addr": "8.8.8.8",
129                "base_stack": "icmp",
130                "event_handlers": [{
131                    "event_pattern": "icmp_connected",
132                    "handler": {
133                        "type": "static",
134                        "actions": [{
135                            "type": "send_echo_request",
136                            "destination_ip": "8.8.8.8",
137                            "identifier": 1234,
138                            "sequence": 1,
139                            "ttl": 64
140                        }]
141                    }
142                }]
143            }),
144        )
145    }
146}
147
148// Implement Client trait (client-specific functionality)
149impl Client for IcmpClientProtocol {
150    fn connect(
151        &self,
152        ctx: crate::protocol::ConnectContext,
153    ) -> std::pin::Pin<
154        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
155    > {
156        Box::pin(async move {
157            crate::client::icmp::IcmpClient::connect_with_llm_actions(
158                ctx.remote_addr,
159                ctx.llm_client,
160                ctx.state,
161                ctx.status_tx,
162                ctx.client_id,
163            )
164            .await
165        })
166    }
167
168    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
169        let action_type = action
170            .get("type")
171            .and_then(|v| v.as_str())
172            .context("Missing 'type' field in action")?;
173
174        match action_type {
175            "send_echo_request" => {
176                let destination_ip = action
177                    .get("destination_ip")
178                    .and_then(|v| v.as_str())
179                    .context("Missing 'destination_ip' parameter")?;
180
181                let identifier = optional_u16(&action, "identifier", 1234)?;
182                let sequence = optional_u16(&action, "sequence", 1)?;
183
184                let payload_hex = action
185                    .get("payload_hex")
186                    .and_then(|v| v.as_str())
187                    .unwrap_or("");
188
189                let ttl = optional_u8(&action, "ttl", 64)?;
190
191                Ok(ClientActionResult::Custom {
192                    name: "send_echo_request".to_string(),
193                    data: json!({
194                        "destination_ip": destination_ip,
195                        "identifier": identifier,
196                        "sequence": sequence,
197                        "payload_hex": payload_hex,
198                        "ttl": ttl,
199                    }),
200                })
201            }
202            /* TODO: Timestamp support requires pnet to add timestamp packet types
203            "send_timestamp_request" => {
204                let destination_ip = action
205                    .get("destination_ip")
206                    .and_then(|v| v.as_str())
207                    .context("Missing 'destination_ip' parameter")?;
208
209                let identifier = action
210                    .get("identifier")
211                    .and_then(|v| v.as_u64())
212                    .unwrap_or(1234) as u16;
213
214                let sequence = action
215                    .get("sequence")
216                    .and_then(|v| v.as_u64())
217                    .unwrap_or(1) as u16;
218
219                Ok(ClientActionResult::Custom {
220                    name: "send_timestamp_request".to_string(),
221                    data: json!({
222                        "destination_ip": destination_ip,
223                        "identifier": identifier,
224                        "sequence": sequence,
225                    }),
226                })
227            }
228            */
229            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
230            "disconnect" => Ok(ClientActionResult::Disconnect),
231            _ => Err(anyhow::anyhow!(
232                "Unknown ICMP client action: {}",
233                action_type
234            )),
235        }
236    }
237}
238
239/// Read an optional 16-bit action parameter.
240///
241/// Absent means `default`. **Present but not a number in range is an error**, which is the
242/// whole point: `as_u64().unwrap_or(1234)` turned `"identifier": "5678"` - a string, which a
243/// model produces readily - into 1234 without a word, so the reply the target sent could never
244/// be matched to the request. Silently substituting a default for a value the caller *did*
245/// supply is the same class of defect as a silent `as u16` truncation.
246fn optional_u16(action: &serde_json::Value, name: &str, default: u16) -> Result<u16> {
247    let Some(value) = action.get(name) else {
248        return Ok(default);
249    };
250    if value.is_null() {
251        return Ok(default);
252    }
253    let raw = value
254        .as_u64()
255        .with_context(|| format!("'{name}' must be a number, got {value}"))?;
256    u16::try_from(raw)
257        .with_context(|| format!("'{name}' must be a 16-bit ICMP field (0-65535), got {raw}"))
258}
259
260/// Read an optional 8-bit action parameter. Same reasoning as [`optional_u16`].
261fn optional_u8(action: &serde_json::Value, name: &str, default: u8) -> Result<u8> {
262    let Some(value) = action.get(name) else {
263        return Ok(default);
264    };
265    if value.is_null() {
266        return Ok(default);
267    }
268    let raw = value
269        .as_u64()
270        .with_context(|| format!("'{name}' must be a number, got {value}"))?;
271    u8::try_from(raw).with_context(|| format!("'{name}' must fit one byte (0-255), got {raw}"))
272}
273
274/// Action definition for send_echo_request
275fn send_echo_request_action() -> ActionDefinition {
276    ActionDefinition {
277        name: "send_echo_request".to_string(),
278        description: "Send an ICMP Echo Request (ping) to a destination".to_string(),
279        parameters: vec![
280            Parameter {
281                name: "destination_ip".to_string(),
282                type_hint: "string".to_string(),
283                description: "Destination IP address (format: X.X.X.X)".to_string(),
284                required: true,
285            },
286            Parameter {
287                name: "identifier".to_string(),
288                type_hint: "number".to_string(),
289                description: "ICMP identifier (default: 1234)".to_string(),
290                required: false,
291            },
292            Parameter {
293                name: "sequence".to_string(),
294                type_hint: "number".to_string(),
295                description: "ICMP sequence number (default: 1)".to_string(),
296                required: false,
297            },
298            Parameter {
299                name: "payload_hex".to_string(),
300                type_hint: "string".to_string(),
301                description: "Payload data as hex string (default: empty)".to_string(),
302                required: false,
303            },
304            Parameter {
305                name: "ttl".to_string(),
306                type_hint: "number".to_string(),
307                description: "Time to live (default: 64)".to_string(),
308                required: false,
309            },
310        ],
311        example: json!({
312            "type": "send_echo_request",
313            "destination_ip": "8.8.8.8",
314            "identifier": 1234,
315            "sequence": 1,
316            "payload_hex": "48656c6c6f",
317            "ttl": 64
318        }),
319        log_template: None,
320    }
321}
322
323/* TODO: Timestamp support requires pnet to add timestamp packet types
324/// Action definition for send_timestamp_request
325fn send_timestamp_request_action() -> ActionDefinition {
326    ActionDefinition {
327        name: "send_timestamp_request".to_string(),
328        description: "Send an ICMP Timestamp Request to a destination".to_string(),
329        parameters: vec![
330            Parameter {
331                name: "destination_ip".to_string(),
332                type_hint: "string".to_string(),
333                description: "Destination IP address (format: X.X.X.X)".to_string(),
334                required: true,
335            },
336            Parameter {
337                name: "identifier".to_string(),
338                type_hint: "number".to_string(),
339                description: "ICMP identifier (default: 1234)".to_string(),
340                required: false,
341            },
342            Parameter {
343                name: "sequence".to_string(),
344                type_hint: "number".to_string(),
345                description: "ICMP sequence number (default: 1)".to_string(),
346                required: false,
347            },
348        ],
349        example: json!({
350            "type": "send_timestamp_request",
351            "destination_ip": "192.168.1.1",
352            "identifier": 5678,
353            "sequence": 1
354        }),
355        log_template: None,
356    }
357}
358*/
359
360/// Action definition for wait_for_more
361fn wait_for_more_action() -> ActionDefinition {
362    ActionDefinition {
363        name: "wait_for_more".to_string(),
364        description: "Wait for more ICMP responses".to_string(),
365        parameters: vec![],
366        example: json!({"type": "wait_for_more"}),
367        log_template: None,
368    }
369}
370
371/// Action definition for disconnect
372fn disconnect_action() -> ActionDefinition {
373    ActionDefinition {
374        name: "disconnect".to_string(),
375        description: "Close the ICMP client".to_string(),
376        parameters: vec![],
377        example: json!({"type": "disconnect"}),
378        log_template: None,
379    }
380}
381
382// ============================================================================
383// ICMP Client Event Type Constants
384// ============================================================================
385
386pub static ICMP_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
387    EventType::new(
388        "icmp_connected",
389        "ICMP client socket created",
390        json!({
391            "type": "send_echo_request",
392            "destination_ip": "8.8.8.8",
393            "identifier": 1234,
394            "sequence": 1,
395            "ttl": 64
396        }),
397    )
398    .with_parameters(vec![
399        Parameter {
400            name: "local_addr".to_string(),
401            type_hint: "string".to_string(),
402            description: "Local address".to_string(),
403            required: true,
404        },
405        Parameter {
406            name: "target_ip".to_string(),
407            type_hint: "string".to_string(),
408            description: "Target IP address".to_string(),
409            required: true,
410        },
411    ])
412});
413
414pub static ICMP_ECHO_REPLY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
415    EventType::new(
416        "icmp_echo_reply",
417        "ICMP Echo Reply (ping response) received",
418        json!({
419            "type": "send_echo_request",
420            "destination_ip": "8.8.8.8",
421            "identifier": 1234,
422            "sequence": 2,
423            "ttl": 64
424        }),
425    )
426    .with_parameters(vec![
427        Parameter {
428            name: "source_ip".to_string(),
429            type_hint: "string".to_string(),
430            description: "Source IP address of the reply".to_string(),
431            required: true,
432        },
433        Parameter {
434            name: "identifier".to_string(),
435            type_hint: "number".to_string(),
436            description: "ICMP identifier".to_string(),
437            required: true,
438        },
439        Parameter {
440            name: "sequence".to_string(),
441            type_hint: "number".to_string(),
442            description: "ICMP sequence number".to_string(),
443            required: true,
444        },
445        Parameter {
446            name: "rtt_ms".to_string(),
447            type_hint: "number".to_string(),
448            description: "Round-trip time in milliseconds".to_string(),
449            required: false,
450        },
451        Parameter {
452            name: "ttl".to_string(),
453            type_hint: "number".to_string(),
454            description: "TTL from IP header".to_string(),
455            required: false,
456        },
457        Parameter {
458            name: "payload_hex".to_string(),
459            type_hint: "string".to_string(),
460            description: "Payload data as hex".to_string(),
461            required: false,
462        },
463    ])
464    .with_actions(vec![
465        send_echo_request_action(),
466        wait_for_more_action(),
467        disconnect_action(),
468    ])
469});
470
471pub static ICMP_TIMEOUT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
472    EventType::new(
473        "icmp_timeout",
474        "ICMP request timed out without reply",
475        json!({
476            "type": "send_echo_request",
477            "destination_ip": "8.8.8.8",
478            "identifier": 1234,
479            "sequence": 2,
480            "ttl": 64
481        }),
482    )
483    .with_parameters(vec![
484        Parameter {
485            name: "destination_ip".to_string(),
486            type_hint: "string".to_string(),
487            description: "Destination IP address".to_string(),
488            required: true,
489        },
490        Parameter {
491            name: "identifier".to_string(),
492            type_hint: "number".to_string(),
493            description: "ICMP identifier".to_string(),
494            required: true,
495        },
496        Parameter {
497            name: "sequence".to_string(),
498            type_hint: "number".to_string(),
499            description: "ICMP sequence number".to_string(),
500            required: true,
501        },
502    ])
503});
504
505pub static ICMP_DEST_UNREACHABLE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
506    EventType::new(
507        "icmp_destination_unreachable",
508        "ICMP Destination Unreachable received",
509        json!({
510            "type": "wait_for_more"
511        }),
512    )
513    .with_parameters(vec![
514        Parameter {
515            name: "source_ip".to_string(),
516            type_hint: "string".to_string(),
517            description: "Source IP of the unreachable message".to_string(),
518            required: true,
519        },
520        Parameter {
521            name: "code".to_string(),
522            type_hint: "number".to_string(),
523            description: "Unreachable code (0=net, 1=host, 2=protocol, 3=port, etc.)".to_string(),
524            required: true,
525        },
526    ])
527});
528
529pub static ICMP_TIME_EXCEEDED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
530    EventType::new(
531        "icmp_time_exceeded",
532        "ICMP Time Exceeded (TTL=0) received - used in traceroute",
533        json!({
534            "type": "send_echo_request",
535            "destination_ip": "8.8.8.8",
536            "identifier": 1234,
537            "sequence": 1,
538            "ttl": 2
539        }),
540    )
541    .with_parameters(vec![
542        Parameter {
543            name: "source_ip".to_string(),
544            type_hint: "string".to_string(),
545            description: "Source IP of the time exceeded message (hop address)".to_string(),
546            required: true,
547        },
548        Parameter {
549            name: "code".to_string(),
550            type_hint: "number".to_string(),
551            description: "Time exceeded code (0=TTL, 1=fragment reassembly)".to_string(),
552            required: true,
553        },
554    ])
555});
556
557pub fn get_icmp_client_event_types() -> Vec<EventType> {
558    vec![
559        ICMP_CLIENT_CONNECTED_EVENT.clone(),
560        ICMP_ECHO_REPLY_EVENT.clone(),
561        ICMP_TIMEOUT_EVENT.clone(),
562        ICMP_DEST_UNREACHABLE_EVENT.clone(),
563        ICMP_TIME_EXCEEDED_EVENT.clone(),
564    ]
565}