1use 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
12pub struct IcmpClientProtocol;
14
15impl IcmpClientProtocol {
16 pub fn new() -> Self {
17 Self
18 }
19}
20
21impl 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 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 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 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 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
148impl 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 "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
239fn 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
260fn 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
274fn 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
323fn 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
371fn 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
382pub 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}