netget/client/icmp/mod.rs
1//! ICMP (Internet Control Message Protocol) client implementation
2//!
3//! This module provides functionality to send ICMP messages and receive responses.
4//! It uses raw IP sockets via socket2 and pnet for ICMP packet handling.
5
6pub mod actions;
7
8use anyhow::{Context, Result};
9use pnet::packet::icmp::echo_reply::EchoReplyPacket;
10use pnet::packet::icmp::time_exceeded::TimeExceededPacket;
11// Note: pnet doesn't provide timestamp_reply packet types
12use crate::utils::clock::Instant;
13use pnet::packet::icmp::{destination_unreachable::DestinationUnreachablePacket, IcmpPacket};
14use pnet::packet::icmp::{IcmpCode, IcmpTypes, MutableIcmpPacket};
15use pnet::packet::ip::IpNextHeaderProtocols;
16use pnet::packet::ipv4::{Ipv4Packet, MutableIpv4Packet};
17use pnet::packet::Packet;
18use socket2::{Domain, Protocol, Socket, Type};
19use std::collections::HashMap;
20use std::net::{Ipv4Addr, SocketAddr};
21use std::sync::Arc;
22use tokio::sync::{mpsc, Mutex};
23use tracing::{debug, error};
24
25use crate::client::llm_budget::call_llm_for_client;
26use crate::llm::actions::client_trait::{Client, ClientActionResult};
27// Imported anonymously: `socket2::Protocol` already owns the name here, and only the
28// trait's methods (`protocol_name`) are needed.
29use crate::llm::actions::protocol_trait::Protocol as _;
30use crate::llm::ollama_client::OllamaClient;
31use crate::protocol::Event;
32use crate::state::app_state::AppState;
33use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
34use crate::state::{AccessLogOwner, ClientId, ClientStatus};
35use crate::{console_debug, console_info};
36
37pub use actions::IcmpClientProtocol;
38use actions::{
39 ICMP_CLIENT_CONNECTED_EVENT, ICMP_DEST_UNREACHABLE_EVENT, ICMP_ECHO_REPLY_EVENT,
40 ICMP_TIME_EXCEEDED_EVENT,
41};
42
43/// Per-client data for LLM handling.
44///
45/// Deliberately just the memory. There used to be a `ConnectionState`
46/// (`Idle`/`Processing`/`Accumulating`) here, copied from the connection-oriented servers and
47/// carrying `#[allow(dead_code)]` on two of its three variants: it was written once at
48/// construction and never read or transitioned, while this client's own CLAUDE.md described it
49/// as the thing that "prevents concurrent LLM calls on same client". Nothing prevented
50/// anything. It is gone rather than wired up, because the mechanism that actually serialises
51/// this client is its single receive loop awaiting each `call_llm_for_client` inline.
52struct ClientData {
53 memory: String,
54}
55
56/// ICMP client that sends requests and receives responses
57pub struct IcmpClient;
58
59/// Pending ICMP request tracking
60#[derive(Clone)]
61struct PendingRequest {
62 sent_at: Instant,
63 identifier: u16,
64 sequence: u16,
65 destination_ip: Ipv4Addr,
66}
67
68/// How long a request waits for its reply before `icmp_timeout` is raised.
69const ICMP_REPLY_TIMEOUT_SECS: u64 = 5;
70
71/// Largest payload that still fits an ICMP message inside one unfragmented IPv4 datagram:
72/// 65535 total, less the 20-byte IPv4 header and the 8-byte ICMP header.
73const MAX_ICMP_PAYLOAD: usize = 65535 - 20 - 8;
74
75impl IcmpClient {
76 /// Connect ICMP client with LLM action handling
77 pub async fn connect_with_llm_actions(
78 remote_addr: String,
79 llm_client: OllamaClient,
80 app_state: Arc<AppState>,
81 status_tx: mpsc::UnboundedSender<String>,
82 client_id: ClientId,
83 ) -> Result<SocketAddr> {
84 // Parse target IP from remote_addr
85 let target_ip: Ipv4Addr = remote_addr
86 .split(':')
87 .next()
88 .context("Invalid remote address format")?
89 .parse()
90 .context("Invalid IPv4 address")?;
91
92 console_info!(status_tx, "ICMP client connecting to {}", target_ip);
93
94 // Create raw ICMP socket
95 let socket = Socket::new(Domain::IPV4, Type::RAW, Some(Protocol::ICMPV4))
96 .context("Failed to create raw ICMP socket (need root/CAP_NET_RAW)")?;
97
98 // `build_echo_request` produces a *complete* IPv4 packet, header included. Without
99 // IP_HDRINCL the kernel prepends a header of its own and our 20 bytes become the first
100 // 20 bytes of the ICMP message, so the target sees ICMP type 0x45 (69, unassigned) and
101 // no ping this client sent could ever have been answered. It is also what makes the
102 // advertised `ttl` parameter mean anything: without the option the kernel picks the TTL
103 // and traceroute is impossible.
104 socket
105 .set_header_included_v4(true)
106 .context("Failed to set IP_HDRINCL on the raw ICMP socket")?;
107
108 // Set socket to non-blocking
109 socket
110 .set_nonblocking(true)
111 .context("Failed to set socket non-blocking")?;
112
113 let local_addr = SocketAddr::new(std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0);
114
115 // Shared state for pending requests
116 let pending_requests: Arc<Mutex<HashMap<(u16, u16), PendingRequest>>> =
117 Arc::new(Mutex::new(HashMap::new()));
118
119 // Initialize client data
120 let client_data = Arc::new(Mutex::new(ClientData {
121 memory: String::new(),
122 }));
123
124 let socket = Arc::new(socket);
125 let protocol = Arc::new(IcmpClientProtocol::new());
126
127 // Command channel for injected actions (the dashboard's [ send ]). Registered, and
128 // drained by a live task, BEFORE the connected-event LLM call below: that call is
129 // awaited inline here, so a manual `*` routing rule parks it - and the whole point
130 // of the channel is that the operator can still reach the client while it waits.
131 let command_rx =
132 crate::client::command_support::register_command_channel(&app_state, client_id).await;
133 // Filled in once the receive loop exists (it is spawned after the connected-event
134 // call), so an injected `disconnect` can actually stop it and release the socket.
135 let read_abort: Arc<std::sync::OnceLock<tokio::task::AbortHandle>> =
136 Arc::new(std::sync::OnceLock::new());
137 let cmd_task = tokio::spawn(Self::command_loop(
138 command_rx,
139 protocol.clone(),
140 socket.clone(),
141 pending_requests.clone(),
142 target_ip,
143 client_id,
144 app_state.clone(),
145 status_tx.clone(),
146 read_abort.clone(),
147 ));
148 app_state.register_client_task(client_id, cmd_task).await;
149
150 // Call LLM with connected event
151 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
152 let event = Event::new(
153 &ICMP_CLIENT_CONNECTED_EVENT,
154 serde_json::json!({
155 "local_addr": local_addr.to_string(),
156 "target_ip": target_ip.to_string(),
157 }),
158 );
159
160 // Copy the memory out and drop the guard *before* the call. A
161 // `client_data.lock().await.memory` written inline in the scrutinee keeps its
162 // temporary guard alive for the whole `match`, so the `Ok` arm below - which locks
163 // again to store `memory_updates` - waited on a lock only it could release.
164 // `tokio::sync::Mutex` is not reentrant: that is a permanent hang, and it fired on
165 // every successful call that returned memory.
166 let memory_snapshot = client_data.lock().await.memory.clone();
167
168 match call_llm_for_client(
169 &llm_client,
170 &app_state,
171 client_id.to_string(),
172 &instruction,
173 &memory_snapshot,
174 Some(&event),
175 protocol.as_ref(),
176 &status_tx,
177 )
178 .await
179 {
180 Ok(result) => {
181 // Update memory if provided
182 if let Some(new_memory) = result.memory_updates {
183 client_data.lock().await.memory = new_memory;
184 }
185
186 // Execute initial actions
187 if let Err(e) = Self::execute_actions(
188 result.actions,
189 &socket,
190 &pending_requests,
191 target_ip,
192 &status_tx,
193 protocol.as_ref(),
194 )
195 .await
196 {
197 // Early return: drop the command handle so the dashboard does not
198 // offer [ send ] into a client that never started.
199 app_state.remove_client_handle(client_id).await;
200 return Err(e);
201 }
202 }
203 Err(e) => {
204 // ICMP defines no way to tell a peer "I cannot answer" - see the server's
205 // CLAUDE.md - so nothing goes on the wire and the log carries the decision.
206 error!(
207 "ICMP client {} decision=fail_closed_llm_error on icmp_connected (nothing sent): {}",
208 client_id, e
209 );
210 }
211 }
212 }
213
214 // An injected `disconnect` that arrived while the connected-event call was parked
215 // has already dropped the command handle. Honour it rather than starting a receive
216 // loop the operator just asked to stop.
217 if !app_state.has_client_handle(client_id).await {
218 return Ok(local_addr);
219 }
220
221 // Spawn receive loop
222 let socket_clone = socket.clone();
223 let llm_clone = llm_client.clone();
224 let state_clone = app_state.clone();
225 let status_clone = status_tx.clone();
226 let protocol_clone = protocol.clone();
227 let pending_clone = pending_requests.clone();
228 let client_data_clone = client_data.clone();
229
230 // Registered with AppState so stop_client can abort this task —
231 // dropping a JoinHandle only detaches it in Tokio.
232 let task_registrar = app_state.clone();
233 let task_handle = tokio::spawn(async move {
234 let mut buffer = vec![std::mem::MaybeUninit::uninit(); 65535];
235
236 loop {
237 // Try to receive packet (non-blocking)
238 match socket_clone.recv_from(&mut buffer) {
239 Ok((n, _src_addr)) => {
240 let data = unsafe {
241 std::slice::from_raw_parts(buffer.as_ptr() as *const u8, n).to_vec()
242 };
243
244 // Parse IP packet
245 let ip_packet = match Ipv4Packet::new(&data) {
246 Some(p) => p,
247 None => continue,
248 };
249
250 // Check if it's ICMP
251 if ip_packet.get_next_level_protocol() != IpNextHeaderProtocols::Icmp {
252 continue;
253 }
254
255 // Parse ICMP packet
256 let icmp_packet = match IcmpPacket::new(ip_packet.payload()) {
257 Some(p) => p,
258 None => continue,
259 };
260
261 let source_ip = ip_packet.get_source();
262 let ttl = ip_packet.get_ttl();
263 let icmp_type = icmp_packet.get_icmp_type();
264 let icmp_payload = icmp_packet.payload();
265
266 console_debug!(
267 status_clone,
268 "ICMP client received {} from {}",
269 icmp_type_to_string(icmp_type),
270 source_ip
271 );
272
273 // Process based on ICMP type
274 let event_opt = match icmp_type {
275 IcmpTypes::EchoReply => {
276 if let Some(echo_reply) = EchoReplyPacket::new(icmp_payload) {
277 let identifier = echo_reply.get_identifier();
278 let sequence = echo_reply.get_sequence_number();
279 let payload_hex = hex::encode(echo_reply.payload());
280
281 // Calculate RTT
282 let rtt_ms = {
283 let mut pending = pending_clone.lock().await;
284 if let Some(req) = pending.remove(&(identifier, sequence)) {
285 req.sent_at.elapsed().as_millis() as u64
286 } else {
287 0
288 }
289 };
290
291 Some(Event::new(
292 &ICMP_ECHO_REPLY_EVENT,
293 serde_json::json!({
294 "source_ip": source_ip.to_string(),
295 "identifier": identifier,
296 "sequence": sequence,
297 "rtt_ms": rtt_ms,
298 "ttl": ttl,
299 "payload_hex": payload_hex,
300 }),
301 ))
302 } else {
303 None
304 }
305 }
306 IcmpTypes::DestinationUnreachable => {
307 if let Some(dest_unreach) =
308 DestinationUnreachablePacket::new(icmp_payload)
309 {
310 let code = dest_unreach.get_icmp_code().0;
311 Some(Event::new(
312 &ICMP_DEST_UNREACHABLE_EVENT,
313 serde_json::json!({
314 "source_ip": source_ip.to_string(),
315 "code": code,
316 }),
317 ))
318 } else {
319 None
320 }
321 }
322 IcmpTypes::TimeExceeded => {
323 if let Some(time_exceeded) = TimeExceededPacket::new(icmp_payload) {
324 let code = time_exceeded.get_icmp_code().0;
325 Some(Event::new(
326 &ICMP_TIME_EXCEEDED_EVENT,
327 serde_json::json!({
328 "source_ip": source_ip.to_string(),
329 "code": code,
330 }),
331 ))
332 } else {
333 None
334 }
335 }
336 /* TODO: Timestamp support requires pnet to add timestamp_reply packet types
337 IcmpTypes::TimestampReply => {
338 if let Some(_ts_reply) = TimestampReplyPacket::new(icmp_payload) {
339 // Could add timestamp reply event here
340 None
341 } else {
342 None
343 }
344 }
345 */
346 _ => None,
347 };
348
349 if let Some(event) = event_opt {
350 // Get instruction for LLM call
351 if let Some(instruction) =
352 state_clone.get_instruction_for_client(client_id).await
353 {
354 // Snapshot the memory and release the lock before the call -
355 // holding the guard in the scrutinee deadlocks the `Ok` arm
356 // below. See the connected-event path.
357 let memory_snapshot = client_data_clone.lock().await.memory.clone();
358
359 // Call LLM
360 match call_llm_for_client(
361 &llm_clone,
362 &state_clone,
363 client_id.to_string(),
364 &instruction,
365 &memory_snapshot,
366 Some(&event),
367 protocol_clone.as_ref(),
368 &status_clone,
369 )
370 .await
371 {
372 Ok(result) => {
373 // Update memory if provided
374 if let Some(new_memory) = result.memory_updates {
375 client_data_clone.lock().await.memory = new_memory;
376 }
377
378 // Execute actions
379 if let Err(e) = Self::execute_actions(
380 result.actions,
381 &socket_clone,
382 &pending_clone,
383 target_ip,
384 &status_clone,
385 protocol_clone.as_ref(),
386 )
387 .await
388 {
389 error!("Failed to execute ICMP action: {}", e);
390 }
391 }
392 Err(e) => {
393 error!(
394 "ICMP client {} decision=fail_closed_llm_error on {} (nothing sent): {}",
395 client_id,
396 event.event_type.id,
397 e
398 );
399 }
400 }
401 }
402 }
403 }
404 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
405 // No data available. Sweep for requests that will never be
406 // answered before sleeping.
407 //
408 // `icmp_timeout` is advertised in get_event_types(), so the model
409 // can be told it exists and write a handler for it -- and nothing
410 // raised it, so that handler waited forever. PendingRequest has
411 // carried `sent_at`, `identifier`, `sequence` and
412 // `destination_ip` for exactly this all along, every one of them
413 // marked #[allow(dead_code)]: the event was designed and never
414 // wired up. A ping that gets no reply IS the interesting result
415 // for a reachability check, and it was the one outcome the model
416 // was never told about.
417 let expired: Vec<PendingRequest> = {
418 let mut pending = pending_clone.lock().await;
419 let now = Instant::now();
420 let stale: Vec<(u16, u16)> = pending
421 .iter()
422 .filter(|(_, r)| {
423 now.duration_since(r.sent_at)
424 > std::time::Duration::from_secs(ICMP_REPLY_TIMEOUT_SECS)
425 })
426 .map(|(k, _)| *k)
427 .collect();
428 stale.iter().filter_map(|k| pending.remove(k)).collect()
429 };
430 for req in expired {
431 tracing::info!(
432 "ICMP client {} request to {} (id {}, seq {}) timed out",
433 client_id,
434 req.destination_ip,
435 req.identifier,
436 req.sequence
437 );
438 if let Some(instruction) =
439 state_clone.get_instruction_for_client(client_id).await
440 {
441 let event = Event::new(
442 &crate::client::icmp::actions::ICMP_TIMEOUT_EVENT,
443 serde_json::json!({
444 "destination_ip": req.destination_ip.to_string(),
445 "identifier": req.identifier,
446 "sequence": req.sequence,
447 "waited_ms": req.sent_at.elapsed().as_millis() as u64,
448 }),
449 );
450 // Same guard-in-the-scrutinee deadlock as the two calls
451 // above: the `.clone()` copied the string but did nothing about
452 // the lock, which stayed held for the whole `match`.
453 let memory_snapshot = client_data_clone.lock().await.memory.clone();
454
455 match call_llm_for_client(
456 &llm_clone,
457 &state_clone,
458 client_id.to_string(),
459 &instruction,
460 &memory_snapshot,
461 Some(&event),
462 protocol_clone.as_ref(),
463 &status_clone,
464 )
465 .await
466 {
467 Ok(result) => {
468 if let Some(new_memory) = result.memory_updates {
469 client_data_clone.lock().await.memory = new_memory;
470 }
471 if let Err(e) = Self::execute_actions(
472 result.actions,
473 &socket_clone,
474 &pending_clone,
475 target_ip,
476 &status_clone,
477 protocol_clone.as_ref(),
478 )
479 .await
480 {
481 error!(
482 "ICMP client {} timeout action failed: {}",
483 client_id, e
484 );
485 }
486 }
487 Err(e) => error!(
488 "ICMP client {} decision=fail_closed_llm_error on \
489 icmp_timeout (nothing sent): {}",
490 client_id, e
491 ),
492 }
493 }
494 }
495 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
496 continue;
497 }
498 Err(e) => {
499 error!("ICMP receive error: {}", e);
500 break;
501 }
502 }
503 }
504 // Every exit path lands here: drop the command handle so the dashboard stops
505 // offering [ send ] on a dead client, and a late send fails fast.
506 state_clone.remove_client_handle(client_id).await;
507 let _ = status_clone.send("__UPDATE_UI__".to_string());
508 });
509 let _ = read_abort.set(task_handle.abort_handle());
510 task_registrar
511 .register_client_task(client_id, task_handle)
512 .await;
513
514 Ok(local_addr)
515 }
516
517 /// Drain injected commands until the channel closes (client removed) or an injected
518 /// `disconnect` ends the session.
519 ///
520 /// The generic `command_support::handle_stream_client_command` cannot serve this client:
521 /// `send_echo_request` yields `ClientActionResult::Custom` and the transport is a raw
522 /// `socket2::Socket`, not an `AsyncWrite`. The action goes through [`Self::apply_action`]
523 /// - the same function the connected-event path and the receive loop use.
524 #[allow(clippy::too_many_arguments)]
525 async fn command_loop(
526 mut command_rx: tokio::sync::mpsc::Receiver<ClientCommand>,
527 protocol: Arc<IcmpClientProtocol>,
528 socket: Arc<Socket>,
529 pending_requests: Arc<Mutex<HashMap<(u16, u16), PendingRequest>>>,
530 target_ip: Ipv4Addr,
531 client_id: ClientId,
532 app_state: Arc<AppState>,
533 status_tx: mpsc::UnboundedSender<String>,
534 read_abort: Arc<std::sync::OnceLock<tokio::task::AbortHandle>>,
535 ) {
536 while let Some(command) = command_rx.recv().await {
537 let action = command.action.clone();
538 let outcome = match protocol.execute_action(action.clone()) {
539 Err(e) => Ok(ClientSendOutcome::Rejected {
540 error: e.to_string(),
541 }),
542 Ok(result) => {
543 Self::apply_action(result, &socket, &pending_requests, target_ip, &status_tx)
544 .await
545 .map(|applied| match applied {
546 Applied::Disconnect => ClientSendOutcome::Disconnected,
547 Applied::Sent(0) => ClientSendOutcome::Executed {
548 detail: "executed (no packet sent)".to_string(),
549 },
550 Applied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
551 Applied::Nothing(detail) => ClientSendOutcome::Executed { detail },
552 })
553 }
554 };
555
556 let outcome_json = match &outcome {
557 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
558 Err(e) => serde_json::json!({"error": e.to_string()}),
559 };
560 app_state
561 .record_access_log(
562 AccessLogOwner::Client(client_id.as_u32()),
563 protocol.protocol_name(),
564 None,
565 "injected_action",
566 action,
567 vec![outcome_json],
568 )
569 .await;
570
571 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
572 if let Err(e) = &outcome {
573 error!("ICMP client {} injected action failed: {}", client_id, e);
574 let _ = status_tx.send(format!(
575 "[WARN] Client {} injected action failed: {}",
576 client_id, e
577 ));
578 }
579 let _ = status_tx.send("__UPDATE_UI__".to_string());
580 crate::client::command_support::reply(command, outcome);
581
582 if disconnect {
583 // ICMP is connectionless, so "disconnected" means: stop receiving, release
584 // the raw socket, drop the handle so [ send ] is greyed out again.
585 if let Some(abort) = read_abort.get() {
586 abort.abort();
587 }
588 app_state.remove_client_handle(client_id).await;
589 app_state
590 .update_client_status(client_id, ClientStatus::Disconnected)
591 .await;
592 let _ = status_tx.send("__UPDATE_UI__".to_string());
593 break;
594 }
595 }
596 }
597
598 /// Execute actions from LLM
599 async fn execute_actions(
600 actions: Vec<serde_json::Value>,
601 socket: &Arc<Socket>,
602 pending_requests: &Arc<Mutex<HashMap<(u16, u16), PendingRequest>>>,
603 target_ip: Ipv4Addr,
604 status_tx: &mpsc::UnboundedSender<String>,
605 protocol: &IcmpClientProtocol,
606 ) -> Result<()> {
607 for action in actions {
608 let result = protocol.execute_action(action)?;
609 if matches!(
610 Self::apply_action(result, socket, pending_requests, target_ip, status_tx).await?,
611 Applied::Disconnect
612 ) {
613 debug!("ICMP client disconnect requested");
614 break;
615 }
616 }
617
618 Ok(())
619 }
620
621 /// Apply one executed action. Shared by the connected-event path, the receive loop and
622 /// injected commands so the echo-request encoding exists exactly once.
623 async fn apply_action(
624 result: ClientActionResult,
625 socket: &Arc<Socket>,
626 pending_requests: &Arc<Mutex<HashMap<(u16, u16), PendingRequest>>>,
627 target_ip: Ipv4Addr,
628 status_tx: &mpsc::UnboundedSender<String>,
629 ) -> Result<Applied> {
630 match result {
631 ClientActionResult::Custom { name, data } => {
632 if name == "send_echo_request" {
633 let dest_ip: Ipv4Addr = data["destination_ip"]
634 .as_str()
635 .unwrap_or(&target_ip.to_string())
636 .parse()?;
637 let identifier =
638 crate::client::wire_values::number::<u16>(&data, "identifier", 1234)?;
639 let sequence = crate::client::wire_values::number::<u16>(&data, "sequence", 1)?;
640 let payload_hex = data["payload_hex"].as_str().unwrap_or("");
641 let ttl = crate::client::wire_values::number::<u8>(&data, "ttl", 64)?;
642
643 let payload = if payload_hex.is_empty() {
644 Vec::new()
645 } else {
646 hex::decode(payload_hex)?
647 };
648
649 // An echo request cannot be larger than one unfragmented IPv4 datagram;
650 // past this `set_total_length(ip_size as u16)` wraps and the header stops
651 // describing the buffer.
652 anyhow::ensure!(
653 payload.len() <= MAX_ICMP_PAYLOAD,
654 "payload_hex decodes to {} bytes; an ICMP echo request carries at most {}",
655 payload.len(),
656 MAX_ICMP_PAYLOAD
657 );
658
659 // Build and send echo request
660 let mut packet = Self::build_echo_request(
661 Ipv4Addr::UNSPECIFIED,
662 dest_ip,
663 identifier,
664 sequence,
665 &payload,
666 ttl,
667 );
668
669 // Last thing before the syscall: Darwin and FreeBSD want two header fields
670 // in host byte order on an IP_HDRINCL socket. After this the buffer is no
671 // longer a well-formed RFC 791 header, so nothing may parse it again.
672 crate::server::icmp::prepare_ipv4_for_raw_send(&mut packet);
673
674 let dest_addr = SocketAddr::new(std::net::IpAddr::V4(dest_ip), 0);
675 let sent = socket.send_to(&packet, &dest_addr.into())?;
676
677 // Track pending request
678 {
679 let mut pending = pending_requests.lock().await;
680 pending.insert(
681 (identifier, sequence),
682 PendingRequest {
683 sent_at: Instant::now(),
684 identifier,
685 sequence,
686 destination_ip: dest_ip,
687 },
688 );
689 }
690
691 console_debug!(
692 status_tx,
693 "ICMP sent echo request to {} (id={}, seq={})",
694 dest_ip,
695 identifier,
696 sequence
697 );
698 /* TODO: Timestamp support requires pnet to add timestamp packet types
699 } else if name == "send_timestamp_request" {
700 // TODO: Implement timestamp request
701 debug!("Timestamp request not yet implemented");
702 */
703 Ok(Applied::Sent(sent))
704 } else {
705 Ok(Applied::Nothing(format!(
706 "custom result '{name}' is not an ICMP request; nothing sent"
707 )))
708 }
709 }
710 ClientActionResult::WaitForMore => {
711 // Just continue listening
712 debug!("ICMP client waiting for more responses");
713 Ok(Applied::Nothing("wait_for_more".to_string()))
714 }
715 ClientActionResult::Disconnect => Ok(Applied::Disconnect),
716 other => Ok(Applied::Nothing(format!(
717 "action result {other:?} produced no packet"
718 ))),
719 }
720 }
721
722 /// Build an ICMP echo request packet with IP header.
723 ///
724 /// `pub` so `tests/client/icmp/action_codec_test.rs` can assert the bytes against RFC 792
725 /// without a raw socket: this is the only ICMP the client ever emits, and every other test
726 /// of it needs root.
727 pub fn build_echo_request(
728 source_ip: Ipv4Addr,
729 dest_ip: Ipv4Addr,
730 identifier: u16,
731 sequence: u16,
732 payload: &[u8],
733 ttl: u8,
734 ) -> Vec<u8> {
735 use pnet::packet::icmp::echo_request::MutableEchoRequestPacket;
736 use pnet::packet::ipv4::checksum;
737
738 // ICMP echo request: 8 bytes header + payload
739 let icmp_size = 8 + payload.len();
740 let mut icmp_buffer = vec![0u8; icmp_size];
741
742 {
743 let mut echo_req = MutableEchoRequestPacket::new(&mut icmp_buffer).unwrap();
744 echo_req.set_icmp_type(IcmpTypes::EchoRequest);
745 echo_req.set_icmp_code(IcmpCode::new(0));
746 echo_req.set_identifier(identifier);
747 echo_req.set_sequence_number(sequence);
748 echo_req.set_payload(payload);
749 }
750
751 // Calculate ICMP checksum
752 let icmp_checksum = {
753 let icmp_packet = MutableIcmpPacket::new(&mut icmp_buffer).unwrap();
754 pnet::packet::icmp::checksum(&icmp_packet.to_immutable())
755 };
756
757 {
758 let mut echo_req = MutableEchoRequestPacket::new(&mut icmp_buffer).unwrap();
759 echo_req.set_checksum(icmp_checksum);
760 }
761
762 // Wrap in IP packet
763 let ip_size = 20 + icmp_size;
764 let mut ip_buffer = vec![0u8; ip_size];
765
766 {
767 let mut ip_packet = MutableIpv4Packet::new(&mut ip_buffer).unwrap();
768 ip_packet.set_version(4);
769 ip_packet.set_header_length(5);
770 ip_packet.set_dscp(0);
771 ip_packet.set_ecn(0);
772 ip_packet.set_total_length(ip_size as u16);
773 ip_packet.set_identification(0);
774 ip_packet.set_flags(0);
775 ip_packet.set_fragment_offset(0);
776 ip_packet.set_ttl(ttl);
777 ip_packet.set_next_level_protocol(IpNextHeaderProtocols::Icmp);
778 ip_packet.set_source(source_ip);
779 ip_packet.set_destination(dest_ip);
780 ip_packet.set_payload(&icmp_buffer);
781
782 // Calculate IP checksum
783 let ip_checksum = checksum(&ip_packet.to_immutable());
784 ip_packet.set_checksum(ip_checksum);
785 }
786
787 ip_buffer
788 }
789}
790
791/// Convert ICMP type to human-readable string
792fn icmp_type_to_string(icmp_type: pnet::packet::icmp::IcmpType) -> &'static str {
793 match icmp_type {
794 IcmpTypes::EchoReply => "ECHO_REPLY",
795 IcmpTypes::EchoRequest => "ECHO_REQUEST",
796 IcmpTypes::DestinationUnreachable => "DEST_UNREACHABLE",
797 IcmpTypes::SourceQuench => "SOURCE_QUENCH",
798 IcmpTypes::RedirectMessage => "REDIRECT",
799 IcmpTypes::TimeExceeded => "TIME_EXCEEDED",
800 IcmpTypes::ParameterProblem => "PARAMETER_PROBLEM",
801 IcmpTypes::Timestamp => "TIMESTAMP",
802 IcmpTypes::TimestampReply => "TIMESTAMP_REPLY",
803 IcmpTypes::InformationRequest => "INFO_REQUEST",
804 IcmpTypes::InformationReply => "INFO_REPLY",
805 IcmpTypes::AddressMaskRequest => "ADDRMASK_REQUEST",
806 IcmpTypes::AddressMaskReply => "ADDRMASK_REPLY",
807 IcmpTypes::Traceroute => "TRACEROUTE",
808 _ => "UNKNOWN",
809 }
810}
811
812/// What [`IcmpClient::apply_action`] did with one action.
813#[derive(Debug)]
814enum Applied {
815 /// Packet bytes actually handed to `send_to` (0 when nothing was sent).
816 Sent(usize),
817 /// Ran, but put nothing on the wire; the string says why.
818 Nothing(String),
819 /// The session should end.
820 Disconnect,
821}