Skip to main content

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}