Skip to main content

netget/server/icmp/
actions.rs

1//! ICMP protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// Name of the loopback interface on this platform.
15///
16/// Linux and Windows call it `lo`; macOS and the BSDs call it `lo0`. Hardcoding `lo` made the
17/// default binding unresolvable on macOS ("Device 'lo' not found").
18#[cfg(any(
19    target_os = "macos",
20    target_os = "ios",
21    target_os = "freebsd",
22    target_os = "openbsd",
23    target_os = "netbsd",
24    target_os = "dragonfly"
25))]
26const DEFAULT_LOOPBACK_INTERFACE: &str = "lo0";
27#[cfg(not(any(
28    target_os = "macos",
29    target_os = "ios",
30    target_os = "freebsd",
31    target_os = "openbsd",
32    target_os = "netbsd",
33    target_os = "dragonfly"
34)))]
35const DEFAULT_LOOPBACK_INTERFACE: &str = "lo";
36
37/// ICMP protocol action handler
38pub struct IcmpProtocol;
39
40impl IcmpProtocol {
41    pub fn new() -> Self {
42        Self
43    }
44}
45
46// Implement Protocol trait (common functionality)
47impl Protocol for IcmpProtocol {
48    fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
49        // ICMP uses interface-based binding (loopback by default)
50        Some(crate::protocol::BindingDefaults::interface_based(
51            DEFAULT_LOOPBACK_INTERFACE,
52        ))
53    }
54
55    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
56        vec![]
57    }
58
59    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
60        vec![
61            send_echo_reply_action(),
62            send_destination_unreachable_action(),
63            send_time_exceeded_action(),
64            // send_timestamp_reply_action(), // TODO: Removed - timestamp support requires pnet timestamp packet types
65            ignore_icmp_action(),
66        ]
67    }
68
69    fn protocol_name(&self) -> &'static str {
70        "ICMP"
71    }
72
73    fn get_event_types(&self) -> Vec<EventType> {
74        get_icmp_event_types()
75    }
76
77    fn stack_name(&self) -> &'static str {
78        "IP>ICMP"
79    }
80
81    fn keywords(&self) -> Vec<&'static str> {
82        vec!["icmp", "ping", "echo", "traceroute"]
83    }
84
85    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
86        use crate::protocol::metadata::{
87            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
88        };
89
90        ProtocolMetadataV2::builder()
91            .state(DevelopmentState::Experimental)
92            .privilege_requirement(PrivilegeRequirement::RawSockets)
93            .connectionless()
94            // Deliberately silent: RFC 792 defines no failure reply to an echo request, and a
95            // synthesised Destination Unreachable would be a false statement about reachability.
96            // Silence is exactly what a filtered host does, so every peer already handles it.
97            .deliberately_silent()
98            .implementation("Raw IP sockets + pnet for ICMP packet handling")
99            .llm_control("Full control - can respond to all ICMP message types")
100            .e2e_testing(
101                "tests/server/icmp/packet_codec_test.rs is the only ICMP server test that runs \
102                 unprivileged: it asserts every emitted packet field-by-field against RFC 791 \
103                 and RFC 792, that hostile action JSON is refused rather than panicking, and \
104                 that each advertised example is accepted by its own executor. \
105                 tests/capture_startup_reports_failure_test.rs asserts spawn() returns Err \
106                 without raw-socket privilege. tests/server/icmp/e2e_test.rs crafts real echo \
107                 requests and reads the replies off a raw socket, but is #[ignore]d for \
108                 privilege and has never been run in CI.",
109            )
110            .notes(
111                "Requires root/CAP_NET_RAW for raw socket access. Startup failure is reported: \
112                 spawn_with_llm awaits the SOCK_RAW sockets, so a privilege failure lands in \
113                 ServerStatus::Error rather than Running. UNVERIFIED, and this is the whole \
114                 gap: no packet this server builds has ever been observed leaving a real \
115                 socket. Until this pass none could have been correct - the send socket did not \
116                 set IP_HDRINCL, so the kernel prepended its own header and every reply went out \
117                 with our IPv4 header sitting where the ICMP message belongs. That is fixed and \
118                 unit-tested up to the syscall, no further. The 'interface' argument is accepted \
119                 and ignored - the socket receives ICMP from every interface. Note the kernel \
120                 answers echo requests itself, so a userspace reply is a second reply on the \
121                 wire.",
122            )
123            .build()
124    }
125
126    fn description(&self) -> &'static str {
127        "ICMP (Internet Control Message Protocol) server"
128    }
129
130    fn example_prompt(&self) -> &'static str {
131        "Listen for ICMP echo requests on eth0"
132    }
133
134    fn group_name(&self) -> &'static str {
135        "Core"
136    }
137
138    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
139        use crate::llm::actions::StartupExamples;
140
141        // Deterministic: reply to every echo request, swapping source/dest and
142        // echoing the id/sequence/payload, no LLM call.
143        let script = r#"import json, sys
144data = json.load(sys.stdin)
145event = data["event"]
146if data["event_type_id"] == "icmp_echo_request":
147    actions = [{"type": "send_echo_reply",
148                "source_ip": event.get("destination_ip", ""),
149                "destination_ip": event.get("source_ip", ""),
150                "identifier": event.get("identifier", 0),
151                "sequence": event.get("sequence", 0),
152                "payload_hex": event.get("payload_hex", "")}]
153else:
154    actions = []
155print(json.dumps({"actions": actions}))"#;
156
157        StartupExamples::new(
158            json!({
159                "type": "open_server",
160                "interface": "eth0",
161                "base_stack": "icmp",
162                "instruction": "ICMP server that responds to ping requests"
163            }),
164            json!({
165                "type": "open_server",
166                "interface": "eth0",
167                "base_stack": "icmp",
168                "event_handlers": [{
169                    "event_pattern": "icmp_echo_request",
170                    "handler": {
171                        "type": "script",
172                        "language": "python",
173                        "code": script
174                    }
175                }]
176            }),
177            json!({
178                "type": "open_server",
179                "interface": "eth0",
180                "base_stack": "icmp",
181                "event_handlers": [{
182                    "event_pattern": "icmp_echo_request",
183                    "handler": {
184                        "type": "static",
185                        "actions": [{
186                            "type": "ignore_icmp"
187                        }]
188                    }
189                }]
190            }),
191        )
192    }
193}
194
195// Implement Server trait (server-specific functionality)
196impl Server for IcmpProtocol {
197    fn spawn(
198        &self,
199        ctx: crate::protocol::SpawnContext,
200    ) -> std::pin::Pin<
201        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
202    > {
203        Box::pin(async move {
204            use crate::server::icmp::IcmpServer;
205
206            // ICMP uses interface-based binding
207            // Extract interface from context (defaults already applied)
208            let interface = ctx
209                .interface()
210                .context("ICMP requires network interface")?
211                .to_string();
212
213            // Get listen address before moving ctx fields
214            let listen_addr = ctx.legacy_listen_addr();
215
216            // Spawn the ICMP server
217            let _interface_name = IcmpServer::spawn_with_llm(
218                interface,
219                ctx.llm_client,
220                ctx.state,
221                ctx.status_tx,
222                ctx.server_id,
223            )
224            .await?;
225
226            // ICMP doesn't bind to a socket, so return a dummy address
227            // The listen_addr from context is just a placeholder
228            Ok(listen_addr)
229        })
230    }
231
232    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
233        let action_type = action
234            .get("type")
235            .and_then(|v| v.as_str())
236            .context("Missing 'type' field in action")?;
237
238        match action_type {
239            "send_echo_reply" => self.execute_send_echo_reply(action),
240            "send_destination_unreachable" => self.execute_send_destination_unreachable(action),
241            "send_time_exceeded" => self.execute_send_time_exceeded(action),
242            // "send_timestamp_reply" => self.execute_send_timestamp_reply(action), // TODO: Removed - timestamp support requires pnet timestamp packet types
243            "ignore_icmp" => Ok(ActionResult::NoAction),
244            _ => Err(anyhow::anyhow!("Unknown ICMP action: {}", action_type)),
245        }
246    }
247}
248
249/// Largest payload that still fits an ICMP message inside one unfragmented IPv4 datagram:
250/// 65535 total, less the 20-byte IPv4 header and the 8-byte ICMP header.
251const MAX_ICMP_PAYLOAD: usize = 65535 - 20 - 8;
252
253/// Read a required 16-bit action parameter.
254///
255/// `as u16` on a `u64` truncates in silence: an identifier of 70000 becomes 4464 and the reply
256/// no longer matches any request the peer sent. Refusing is the honest answer, and the model is
257/// told the range.
258fn u16_field(action: &serde_json::Value, name: &str) -> Result<u16> {
259    let raw = action
260        .get(name)
261        .and_then(|v| v.as_u64())
262        .with_context(|| format!("Missing '{name}' parameter"))?;
263    u16::try_from(raw)
264        .with_context(|| format!("'{name}' must be a 16-bit ICMP field (0-65535), got {raw}"))
265}
266
267/// Read an 8-bit action parameter, with `default` used when it is absent.
268///
269/// Same reasoning as [`u16_field`]: an ICMP code of 300 silently became 44, which is not a code
270/// at all.
271fn u8_field(action: &serde_json::Value, name: &str, default: Option<u8>) -> Result<u8> {
272    let raw = match action.get(name).and_then(|v| v.as_u64()) {
273        Some(raw) => raw,
274        None => return default.with_context(|| format!("Missing '{name}' parameter")),
275    };
276    u8::try_from(raw).with_context(|| format!("'{name}' must be an ICMP code (0-255), got {raw}"))
277}
278
279/// Read an optional 16-bit field, falling back to `default` when it is absent.
280fn u16_field_or(spec: &serde_json::Value, name: &str, default: u16) -> Result<u16> {
281    let Some(raw) = spec.get(name).and_then(|v| v.as_u64()) else {
282        return Ok(default);
283    };
284    u16::try_from(raw).with_context(|| format!("'{name}' must be 0-65535, got {raw}"))
285}
286
287/// The ones' complement sum RFC 1071 defines, over `bytes` plus a pre-seeded `carry`.
288fn ones_complement(bytes: &[u8], carry: u32) -> u16 {
289    let mut sum = carry;
290    let mut chunks = bytes.chunks_exact(2);
291    for pair in &mut chunks {
292        sum += u32::from(u16::from_be_bytes([pair[0], pair[1]]));
293    }
294    if let [odd] = chunks.remainder() {
295        sum += u32::from(*odd) << 8;
296    }
297    while sum >> 16 != 0 {
298        sum = (sum & 0xffff) + (sum >> 16);
299    }
300    !(sum as u16)
301}
302
303/// Build the 28 bytes RFC 792 asks an ICMP error to quote — the offending IPv4 header plus the
304/// first 64 bits of its transport header — from fields a model can actually read.
305///
306/// This exists because `original_packet_hex` asked the model to *invent* an IPv4 header
307/// whenever it had not literally been handed one: twenty bytes of bit-packed fields ending in
308/// a ones' complement checksum. That is the case `CLAUDE.md`'s "never put raw bytes in action
309/// parameters" rule is about, and a wrong checksum here is silent — the quoted datagram is not
310/// what the peer matches its outstanding probe against, so the error is simply ignored.
311///
312/// The hex escape hatch stays for the one case that genuinely needs it: a relay quoting bytes
313/// it really captured, which it should copy rather than re-derive.
314fn build_quoted_datagram(spec: &serde_json::Value) -> Result<Vec<u8>> {
315    if !spec.is_object() {
316        anyhow::bail!(
317            "'original_packet' must be an object describing the datagram that provoked this \
318             error, e.g. {{\"source_ip\": \"192.168.1.50\", \"destination_ip\": \
319             \"203.0.113.5\", \"protocol\": \"udp\", \"source_port\": 41234, \
320             \"destination_port\": 53}}"
321        );
322    }
323
324    let source: std::net::Ipv4Addr = spec
325        .get("source_ip")
326        .and_then(|v| v.as_str())
327        .context("Missing 'original_packet.source_ip'")?
328        .parse()
329        .context("'original_packet.source_ip' is not an IPv4 address")?;
330    let destination: std::net::Ipv4Addr = spec
331        .get("destination_ip")
332        .and_then(|v| v.as_str())
333        .context("Missing 'original_packet.destination_ip'")?
334        .parse()
335        .context("'original_packet.destination_ip' is not an IPv4 address")?;
336
337    // Accepted as a name or as an IANA number, because both spellings turn up in a model's
338    // vocabulary and neither is ambiguous.
339    let protocol = match spec.get("protocol") {
340        None | Some(serde_json::Value::Null) => 17u8,
341        Some(serde_json::Value::String(name)) => match name.to_ascii_lowercase().as_str() {
342            "udp" => 17,
343            "tcp" => 6,
344            "icmp" => 1,
345            other => anyhow::bail!(
346                "'original_packet.protocol' is {other:?}; use \"udp\", \"tcp\", \"icmp\", or \
347                 supply the whole quotation through 'original_packet_hex'"
348            ),
349        },
350        Some(serde_json::Value::Number(n)) => {
351            let raw = n
352                .as_u64()
353                .context("'original_packet.protocol' must be 0-255")?;
354            u8::try_from(raw).context("'original_packet.protocol' must be 0-255")?
355        }
356        Some(other) => {
357            anyhow::bail!("'original_packet.protocol' must be a string or a number, got {other}")
358        }
359    };
360
361    let ttl = u8_field(spec, "ttl", Some(64))?;
362    let identification = u16_field_or(spec, "identification", 0)?;
363
364    // 20-byte header + the 8 transport bytes the quotation carries. Everything the model can
365    // set is a named field; the two checksums are computed here and never asked for.
366    const TOTAL_LEN: u16 = 28;
367    let mut packet = vec![0u8; TOTAL_LEN as usize];
368    packet[0] = 0x45; // IPv4, 5 * 4 = 20-byte header, no options
369    packet[2..4].copy_from_slice(&TOTAL_LEN.to_be_bytes());
370    packet[4..6].copy_from_slice(&identification.to_be_bytes());
371    packet[8] = ttl;
372    packet[9] = protocol;
373    packet[12..16].copy_from_slice(&source.octets());
374    packet[16..20].copy_from_slice(&destination.octets());
375    let header_checksum = ones_complement(&packet[..20], 0);
376    packet[10..12].copy_from_slice(&header_checksum.to_be_bytes());
377
378    match protocol {
379        17 => {
380            let source_port = u16_field_or(spec, "source_port", 0)?;
381            let destination_port = u16_field_or(spec, "destination_port", 0)?;
382            packet[20..22].copy_from_slice(&source_port.to_be_bytes());
383            packet[22..24].copy_from_slice(&destination_port.to_be_bytes());
384            packet[24..26].copy_from_slice(&8u16.to_be_bytes()); // header only, no payload
385                                                                 // Pseudo-header: source, destination, zero, protocol, UDP length.
386            let carry = u32::from(u16::from_be_bytes([packet[12], packet[13]]))
387                + u32::from(u16::from_be_bytes([packet[14], packet[15]]))
388                + u32::from(u16::from_be_bytes([packet[16], packet[17]]))
389                + u32::from(u16::from_be_bytes([packet[18], packet[19]]))
390                + u32::from(protocol)
391                + 8;
392            let checksum = ones_complement(&packet[20..28], carry);
393            // RFC 768: a computed zero is transmitted as all ones, because zero means
394            // "no checksum".
395            let checksum = if checksum == 0 { 0xffff } else { checksum };
396            packet[26..28].copy_from_slice(&checksum.to_be_bytes());
397        }
398        6 => {
399            let source_port = u16_field_or(spec, "source_port", 0)?;
400            let destination_port = u16_field_or(spec, "destination_port", 0)?;
401            // The quotation reaches only as far as the sequence number, which is exactly what
402            // the peer matches an outstanding connection attempt against.
403            let sequence =
404                u32::try_from(spec.get("sequence").and_then(|v| v.as_u64()).unwrap_or(0))
405                    .context("'original_packet.sequence' must fit in 32 bits")?;
406            packet[20..22].copy_from_slice(&source_port.to_be_bytes());
407            packet[22..24].copy_from_slice(&destination_port.to_be_bytes());
408            packet[24..28].copy_from_slice(&sequence.to_be_bytes());
409        }
410        1 => {
411            let icmp_type = u8_field(spec, "icmp_type", Some(8))?;
412            let icmp_code = u8_field(spec, "icmp_code", Some(0))?;
413            let identifier = u16_field_or(spec, "identifier", 0)?;
414            let sequence = u16_field_or(spec, "sequence", 0)?;
415            packet[20] = icmp_type;
416            packet[21] = icmp_code;
417            packet[24..26].copy_from_slice(&identifier.to_be_bytes());
418            packet[26..28].copy_from_slice(&sequence.to_be_bytes());
419            let checksum = ones_complement(&packet[20..28], 0);
420            packet[22..24].copy_from_slice(&checksum.to_be_bytes());
421        }
422        other => anyhow::bail!(
423            "'original_packet.protocol' {other} has no structured form here — only udp (17), \
424             tcp (6) and icmp (1) do. Supply the whole quotation through 'original_packet_hex'."
425        ),
426    }
427
428    Ok(packet)
429}
430
431/// Resolve the quoted datagram from whichever of the two spellings the action used.
432///
433/// Exactly one, never both, and never sniffed: the structured form and the hex form say the
434/// same thing in incompatible ways, and only the sender knows which it meant.
435fn quoted_datagram(action: &serde_json::Value) -> Result<Vec<u8>> {
436    let structured = action.get("original_packet").filter(|v| !v.is_null());
437    let raw = action
438        .get("original_packet_hex")
439        .and_then(|v| v.as_str())
440        .filter(|s| !s.is_empty());
441
442    match (structured, raw) {
443        (Some(spec), None) => build_quoted_datagram(spec),
444        (None, Some(encoded)) => hex::decode(encoded).context("Invalid hex in original_packet_hex"),
445        (Some(_), Some(_)) => anyhow::bail!(
446            "Give 'original_packet' or 'original_packet_hex', not both — they describe the same \
447             quoted datagram and nothing can tell which one you meant"
448        ),
449        (None, None) => anyhow::bail!(
450            "Missing the quoted datagram. Describe the packet that provoked this error with \
451             'original_packet' ({{\"source_ip\": …, \"destination_ip\": …, \"protocol\": \
452             \"udp\", \"source_port\": …, \"destination_port\": …}}), or pass the bytes you \
453             captured as 'original_packet_hex'"
454        ),
455    }
456}
457
458impl IcmpProtocol {
459    /// Execute send_echo_reply action
460    fn execute_send_echo_reply(&self, action: serde_json::Value) -> Result<ActionResult> {
461        let source_ip = action
462            .get("source_ip")
463            .and_then(|v| v.as_str())
464            .context("Missing 'source_ip' parameter")?;
465
466        let destination_ip = action
467            .get("destination_ip")
468            .and_then(|v| v.as_str())
469            .context("Missing 'destination_ip' parameter")?;
470
471        let identifier = u16_field(&action, "identifier")?;
472        let sequence = u16_field(&action, "sequence")?;
473
474        let payload_hex = action
475            .get("payload_hex")
476            .and_then(|v| v.as_str())
477            .unwrap_or("");
478
479        let payload = if payload_hex.is_empty() {
480            Vec::new()
481        } else {
482            hex::decode(payload_hex).context("Invalid hex in payload_hex")?
483        };
484
485        // `set_total_length(ip_size as u16)` would wrap silently past this, producing a header
486        // whose length field disagrees with the buffer it describes.
487        anyhow::ensure!(
488            payload.len() <= MAX_ICMP_PAYLOAD,
489            "payload_hex decodes to {} bytes; an ICMP echo reply carries at most {}",
490            payload.len(),
491            MAX_ICMP_PAYLOAD
492        );
493
494        // Parse IP addresses
495        let source_ip_parsed: std::net::Ipv4Addr =
496            source_ip.parse().context("Invalid source_ip format")?;
497        let destination_ip_parsed: std::net::Ipv4Addr = destination_ip
498            .parse()
499            .context("Invalid destination_ip format")?;
500
501        // Build ICMP echo reply packet
502        use crate::server::icmp::IcmpServer;
503        let packet = IcmpServer::build_echo_reply(
504            source_ip_parsed,
505            destination_ip_parsed,
506            identifier,
507            sequence,
508            &payload,
509        );
510
511        Ok(ActionResult::Output(packet))
512    }
513
514    /// Execute send_destination_unreachable action
515    fn execute_send_destination_unreachable(
516        &self,
517        action: serde_json::Value,
518    ) -> Result<ActionResult> {
519        let source_ip = action
520            .get("source_ip")
521            .and_then(|v| v.as_str())
522            .context("Missing 'source_ip' parameter")?;
523
524        let destination_ip = action
525            .get("destination_ip")
526            .and_then(|v| v.as_str())
527            .context("Missing 'destination_ip' parameter")?;
528
529        let code = u8_field(&action, "code", None)?;
530
531        let original_packet = quoted_datagram(&action)?;
532
533        // Parse IP addresses
534        let source_ip_parsed: std::net::Ipv4Addr =
535            source_ip.parse().context("Invalid source_ip format")?;
536        let destination_ip_parsed: std::net::Ipv4Addr = destination_ip
537            .parse()
538            .context("Invalid destination_ip format")?;
539
540        // Build ICMP destination unreachable packet
541        use crate::server::icmp::IcmpServer;
542        let packet = IcmpServer::build_destination_unreachable(
543            source_ip_parsed,
544            destination_ip_parsed,
545            code,
546            &original_packet,
547        );
548
549        Ok(ActionResult::Output(packet))
550    }
551
552    /// Execute send_time_exceeded action
553    fn execute_send_time_exceeded(&self, action: serde_json::Value) -> Result<ActionResult> {
554        let source_ip = action
555            .get("source_ip")
556            .and_then(|v| v.as_str())
557            .context("Missing 'source_ip' parameter")?;
558
559        let destination_ip = action
560            .get("destination_ip")
561            .and_then(|v| v.as_str())
562            .context("Missing 'destination_ip' parameter")?;
563
564        // Default 0 = TTL exceeded in transit.
565        let code = u8_field(&action, "code", Some(0))?;
566
567        let original_packet = quoted_datagram(&action)?;
568
569        // Parse IP addresses
570        let source_ip_parsed: std::net::Ipv4Addr =
571            source_ip.parse().context("Invalid source_ip format")?;
572        let destination_ip_parsed: std::net::Ipv4Addr = destination_ip
573            .parse()
574            .context("Invalid destination_ip format")?;
575
576        // Build ICMP time exceeded packet
577        use crate::server::icmp::IcmpServer;
578        let packet = IcmpServer::build_time_exceeded(
579            source_ip_parsed,
580            destination_ip_parsed,
581            code,
582            &original_packet,
583        );
584
585        Ok(ActionResult::Output(packet))
586    }
587
588    /* TODO: Timestamp support requires pnet to add timestamp packet types
589    /// Execute send_timestamp_reply action
590    fn execute_send_timestamp_reply(&self, action: serde_json::Value) -> Result<ActionResult> {
591        let source_ip = action
592            .get("source_ip")
593            .and_then(|v| v.as_str())
594            .context("Missing 'source_ip' parameter")?;
595
596        let destination_ip = action
597            .get("destination_ip")
598            .and_then(|v| v.as_str())
599            .context("Missing 'destination_ip' parameter")?;
600
601        let identifier = action
602            .get("identifier")
603            .and_then(|v| v.as_u64())
604            .context("Missing 'identifier' parameter")? as u16;
605
606        let sequence = action
607            .get("sequence")
608            .and_then(|v| v.as_u64())
609            .context("Missing 'sequence' parameter")? as u16;
610
611        let originate_timestamp = action
612            .get("originate_timestamp")
613            .and_then(|v| v.as_u64())
614            .context("Missing 'originate_timestamp' parameter")? as u32;
615
616        // Parse IP addresses
617        let source_ip_parsed: std::net::Ipv4Addr =
618            source_ip.parse().context("Invalid source_ip format")?;
619        let destination_ip_parsed: std::net::Ipv4Addr =
620            destination_ip.parse().context("Invalid destination_ip format")?;
621
622        // Build ICMP timestamp reply packet
623        use crate::server::icmp::IcmpServer;
624        let packet = IcmpServer::build_timestamp_reply(
625            source_ip_parsed,
626            destination_ip_parsed,
627            identifier,
628            sequence,
629            originate_timestamp,
630        );
631
632        Ok(ActionResult::Output(packet))
633    }
634    */
635}
636
637/// Action definition for send_echo_reply
638fn send_echo_reply_action() -> ActionDefinition {
639    ActionDefinition {
640        name: "send_echo_reply".to_string(),
641        description: "Send an ICMP Echo Reply packet in response to an Echo Request (ping)"
642            .to_string(),
643        parameters: vec![
644            Parameter {
645                name: "source_ip".to_string(),
646                type_hint: "string".to_string(),
647                description: "Source IP address (format: X.X.X.X)".to_string(),
648                required: true,
649            },
650            Parameter {
651                name: "destination_ip".to_string(),
652                type_hint: "string".to_string(),
653                description: "Destination IP address (format: X.X.X.X)".to_string(),
654                required: true,
655            },
656            Parameter {
657                name: "identifier".to_string(),
658                type_hint: "number".to_string(),
659                description: "ICMP identifier (must match request)".to_string(),
660                required: true,
661            },
662            Parameter {
663                name: "sequence".to_string(),
664                type_hint: "number".to_string(),
665                description: "ICMP sequence number (must match request)".to_string(),
666                required: true,
667            },
668            Parameter {
669                name: "payload_hex".to_string(),
670                type_hint: "string".to_string(),
671                description: "Payload data as hex string (must match request)".to_string(),
672                required: false,
673            },
674        ],
675        example: json!({
676            "type": "send_echo_reply",
677            "source_ip": "192.168.1.100",
678            "destination_ip": "192.168.1.50",
679            "identifier": 1234,
680            "sequence": 1,
681            "payload_hex": "48656c6c6f"
682        }),
683        log_template: Some(
684            LogTemplate::new()
685                .with_info("-> ICMP echo reply to {destination_ip}")
686                .with_debug(
687                    "ICMP send_echo_reply: dst={destination_ip} id={identifier} seq={sequence}",
688                ),
689        ),
690    }
691}
692
693/// The structured spelling of RFC 792's quoted datagram, shared by both error actions.
694fn quoted_datagram_parameter() -> Parameter {
695    Parameter {
696        name: "original_packet".to_string(),
697        type_hint: "object".to_string(),
698        description: "The datagram that provoked this error, described as fields: \
699                      'source_ip' and 'destination_ip' (required), 'protocol' (\"udp\" \
700                      default, \"tcp\", \"icmp\", or an IANA number), 'source_port' and \
701                      'destination_port' for udp/tcp, 'ttl' (default 64) and \
702                      'identification' (default 0). The server builds the 20-byte IPv4 \
703                      header and the 8 transport bytes RFC 792 quotes, and computes both \
704                      checksums. Prefer this over 'original_packet_hex' — give exactly one."
705            .to_string(),
706        required: false,
707    }
708}
709
710/// The byte-for-byte spelling, for a relay quoting a datagram it actually saw.
711fn quoted_datagram_hex_parameter() -> Parameter {
712    Parameter {
713        name: "original_packet_hex".to_string(),
714        type_hint: "string".to_string(),
715        description: "Escape hatch: the quoted datagram byte for byte as hex (original IP \
716                      header + first 8 bytes of its payload), for bytes you captured rather \
717                      than invented. Nothing is computed for you, and a wrong header \
718                      checksum makes the peer ignore the error silently. Give this or \
719                      'original_packet', not both."
720            .to_string(),
721        required: false,
722    }
723}
724
725/// Action definition for send_destination_unreachable
726fn send_destination_unreachable_action() -> ActionDefinition {
727    ActionDefinition {
728        name: "send_destination_unreachable".to_string(),
729        description: "Send an ICMP Destination Unreachable message".to_string(),
730        parameters: vec![
731            Parameter {
732                name: "source_ip".to_string(),
733                type_hint: "string".to_string(),
734                description: "Source IP address (format: X.X.X.X)".to_string(),
735                required: true,
736            },
737            Parameter {
738                name: "destination_ip".to_string(),
739                type_hint: "string".to_string(),
740                description: "Destination IP address (format: X.X.X.X)".to_string(),
741                required: true,
742            },
743            Parameter {
744                name: "code".to_string(),
745                type_hint: "number".to_string(),
746                description: "Unreachable code: 0=net, 1=host, 2=protocol, 3=port, 4=fragmentation needed, 5=source route failed".to_string(),
747                required: true,
748            },
749            quoted_datagram_parameter(),
750            quoted_datagram_hex_parameter(),
751        ],
752        // RFC 792 wants the original IP header plus the next 64 bits. Described as fields
753        // rather than as 28 bytes of hex: both checksums in that quotation are ones'
754        // complement sums over the header, which a model cannot compute and cannot proofread,
755        // and a wrong one is silent — the peer simply does not match the error to its probe.
756        example: json!({
757            "type": "send_destination_unreachable",
758            "source_ip": "192.168.1.1",
759            "destination_ip": "192.168.1.50",
760            "code": 3,
761            "original_packet": {
762                "source_ip": "192.168.1.50",
763                "destination_ip": "203.0.113.5",
764                "protocol": "udp",
765                "source_port": 41234,
766                "destination_port": 53
767            }
768        }),
769        log_template: Some(
770            LogTemplate::new()
771                .with_info("-> ICMP unreachable code={code} to {destination_ip}")
772                .with_debug("ICMP send_destination_unreachable: dst={destination_ip} code={code}"),
773        ),
774    }
775}
776
777/// Action definition for send_time_exceeded
778fn send_time_exceeded_action() -> ActionDefinition {
779    ActionDefinition {
780        name: "send_time_exceeded".to_string(),
781        description: "Send an ICMP Time Exceeded message (used in traceroute)".to_string(),
782        parameters: vec![
783            Parameter {
784                name: "source_ip".to_string(),
785                type_hint: "string".to_string(),
786                description: "Source IP address (format: X.X.X.X)".to_string(),
787                required: true,
788            },
789            Parameter {
790                name: "destination_ip".to_string(),
791                type_hint: "string".to_string(),
792                description: "Destination IP address (format: X.X.X.X)".to_string(),
793                required: true,
794            },
795            Parameter {
796                name: "code".to_string(),
797                type_hint: "number".to_string(),
798                description: "Time exceeded code: 0=TTL exceeded in transit, 1=fragment reassembly time exceeded".to_string(),
799                required: false,
800            },
801            quoted_datagram_parameter(),
802            quoted_datagram_hex_parameter(),
803        ],
804        // The original IP header plus the next 64 bits (RFC 792), as fields: a classic UDP
805        // traceroute probe from 192.168.1.50 to 203.0.113.5 with TTL 1. Written out this way
806        // the `ttl: 1` that makes it a traceroute probe is legible, which it is not when the
807        // same fact is bit 8 of a hex blob.
808        example: json!({
809            "type": "send_time_exceeded",
810            "source_ip": "10.0.0.1",
811            "destination_ip": "192.168.1.50",
812            "code": 0,
813            "original_packet": {
814                "source_ip": "192.168.1.50",
815                "destination_ip": "203.0.113.5",
816                "protocol": "udp",
817                "source_port": 41234,
818                "destination_port": 33434,
819                "ttl": 1
820            }
821        }),
822        log_template: Some(
823            LogTemplate::new()
824                .with_info("-> ICMP TTL exceeded to {destination_ip}")
825                .with_debug("ICMP send_time_exceeded: dst={destination_ip} code={code}"),
826        ),
827    }
828}
829
830/* TODO: Timestamp support requires pnet to add timestamp packet types
831/// Action definition for send_timestamp_reply
832fn send_timestamp_reply_action() -> ActionDefinition {
833    ActionDefinition {
834        name: "send_timestamp_reply".to_string(),
835        description: "Send an ICMP Timestamp Reply message".to_string(),
836        parameters: vec![
837            Parameter {
838                name: "source_ip".to_string(),
839                type_hint: "string".to_string(),
840                description: "Source IP address (format: X.X.X.X)".to_string(),
841                required: true,
842            },
843            Parameter {
844                name: "destination_ip".to_string(),
845                type_hint: "string".to_string(),
846                description: "Destination IP address (format: X.X.X.X)".to_string(),
847                required: true,
848            },
849            Parameter {
850                name: "identifier".to_string(),
851                type_hint: "number".to_string(),
852                description: "ICMP identifier (must match request)".to_string(),
853                required: true,
854            },
855            Parameter {
856                name: "sequence".to_string(),
857                type_hint: "number".to_string(),
858                description: "ICMP sequence number (must match request)".to_string(),
859                required: true,
860            },
861            Parameter {
862                name: "originate_timestamp".to_string(),
863                type_hint: "number".to_string(),
864                description:
865                    "Originate timestamp from request (milliseconds since midnight UT)".to_string(),
866                required: true,
867            },
868        ],
869        example: json!({
870            "type": "send_timestamp_reply",
871            "source_ip": "192.168.1.1",
872            "destination_ip": "192.168.1.50",
873            "identifier": 1234,
874            "sequence": 1,
875            "originate_timestamp": 12345678
876        }),
877        log_template: Some(
878            LogTemplate::new()
879                .with_info("-> ICMP timestamp reply to {destination_ip}")
880                .with_debug("ICMP send_timestamp_reply: dst={destination_ip} id={identifier} seq={sequence}"),
881        ),
882    }
883}
884*/
885
886/// Action definition for ignore_icmp
887fn ignore_icmp_action() -> ActionDefinition {
888    ActionDefinition {
889        name: "ignore_icmp".to_string(),
890        description: "Ignore this ICMP packet (no action taken)".to_string(),
891        parameters: vec![],
892        example: json!({
893            "type": "ignore_icmp"
894        }),
895        log_template: Some(
896            LogTemplate::new()
897                .with_info("-> ICMP ignore")
898                .with_debug("ICMP ignore_icmp"),
899        ),
900    }
901}
902
903// ============================================================================
904// ICMP Event Type Constants
905// ============================================================================
906
907pub static ICMP_ECHO_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
908    EventType::new(
909        "icmp_echo_request",
910        "ICMP Echo Request (ping) received from network",
911        json!({
912            "type": "send_echo_reply",
913            "source_ip": "192.168.1.100",
914            "destination_ip": "192.168.1.50",
915            "identifier": 1234,
916            "sequence": 1,
917            "payload_hex": "48656c6c6f"
918        }),
919    )
920    .with_parameters(vec![
921        Parameter {
922            name: "source_ip".to_string(),
923            type_hint: "string".to_string(),
924            description: "Source IP address of the ping request".to_string(),
925            required: true,
926        },
927        Parameter {
928            name: "destination_ip".to_string(),
929            type_hint: "string".to_string(),
930            description: "Destination IP address (our server)".to_string(),
931            required: true,
932        },
933        Parameter {
934            name: "identifier".to_string(),
935            type_hint: "number".to_string(),
936            description: "ICMP identifier".to_string(),
937            required: true,
938        },
939        Parameter {
940            name: "sequence".to_string(),
941            type_hint: "number".to_string(),
942            description: "ICMP sequence number".to_string(),
943            required: true,
944        },
945        Parameter {
946            name: "payload_hex".to_string(),
947            type_hint: "string".to_string(),
948            description: "Hexadecimal representation of the payload data".to_string(),
949            required: false,
950        },
951        Parameter {
952            name: "ttl".to_string(),
953            type_hint: "number".to_string(),
954            description: "Time to live from IP header".to_string(),
955            required: false,
956        },
957    ])
958    .with_actions(vec![send_echo_reply_action(), ignore_icmp_action()])
959});
960
961/* TODO: Timestamp support requires pnet to add timestamp packet types
962pub static ICMP_TIMESTAMP_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
963    EventType::new(
964        "icmp_timestamp_request",
965        "ICMP Timestamp Request received from network",
966        json!({
967            "type": "send_timestamp_reply",
968            "source_ip": "192.168.1.100",
969            "destination_ip": "192.168.1.50",
970            "identifier": 1234,
971            "sequence": 1,
972            "originate_timestamp": 12345678
973        }),
974    )
975    .with_parameters(vec![
976        Parameter {
977            name: "source_ip".to_string(),
978            type_hint: "string".to_string(),
979            description: "Source IP address".to_string(),
980            required: true,
981        },
982        Parameter {
983            name: "destination_ip".to_string(),
984            type_hint: "string".to_string(),
985            description: "Destination IP address".to_string(),
986            required: true,
987        },
988        Parameter {
989            name: "identifier".to_string(),
990            type_hint: "number".to_string(),
991            description: "ICMP identifier".to_string(),
992            required: true,
993        },
994        Parameter {
995            name: "sequence".to_string(),
996            type_hint: "number".to_string(),
997            description: "ICMP sequence number".to_string(),
998            required: true,
999        },
1000        Parameter {
1001            name: "originate_timestamp".to_string(),
1002            type_hint: "number".to_string(),
1003            description: "Originate timestamp (milliseconds since midnight UT)".to_string(),
1004            required: true,
1005        },
1006    ])
1007    .with_actions(vec![send_timestamp_reply_action(), ignore_icmp_action()])
1008    .with_log_template(
1009        LogTemplate::new()
1010            .with_info("ICMP timestamp request from {source_ip}")
1011            .with_debug("ICMP timestamp_request: src={source_ip} id={identifier} seq={sequence}"),
1012    )
1013});
1014*/
1015
1016pub static ICMP_OTHER_MESSAGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1017    EventType::new(
1018        "icmp_other_message",
1019        "Other ICMP message type received (not echo or timestamp)",
1020        json!({
1021            "type": "send_destination_unreachable",
1022            "source_ip": "192.168.1.1",
1023            "destination_ip": "192.168.1.50",
1024            "code": 3,
1025            "original_packet": {
1026                "source_ip": "192.168.1.50",
1027                "destination_ip": "203.0.113.5",
1028                "protocol": "udp",
1029                "source_port": 41234,
1030                "destination_port": 53
1031            }
1032        }),
1033    )
1034    .with_parameters(vec![
1035        Parameter {
1036            name: "source_ip".to_string(),
1037            type_hint: "string".to_string(),
1038            description: "Source IP address".to_string(),
1039            required: true,
1040        },
1041        Parameter {
1042            name: "destination_ip".to_string(),
1043            type_hint: "string".to_string(),
1044            description: "Destination IP address".to_string(),
1045            required: true,
1046        },
1047        Parameter {
1048            name: "icmp_type".to_string(),
1049            type_hint: "number".to_string(),
1050            description: "ICMP message type".to_string(),
1051            required: true,
1052        },
1053        Parameter {
1054            name: "icmp_code".to_string(),
1055            type_hint: "number".to_string(),
1056            description: "ICMP message code".to_string(),
1057            required: true,
1058        },
1059        Parameter {
1060            name: "packet_hex".to_string(),
1061            type_hint: "string".to_string(),
1062            description: "Full ICMP packet as hex".to_string(),
1063            required: false,
1064        },
1065    ])
1066    .with_actions(vec![
1067        send_destination_unreachable_action(),
1068        send_time_exceeded_action(),
1069        ignore_icmp_action(),
1070    ])
1071});
1072
1073pub fn get_icmp_event_types() -> Vec<EventType> {
1074    vec![
1075        ICMP_ECHO_REQUEST_EVENT.clone(),
1076        // ICMP_TIMESTAMP_REQUEST_EVENT.clone(), // TODO: Removed - timestamp support requires pnet timestamp packet types
1077        ICMP_OTHER_MESSAGE_EVENT.clone(),
1078    ]
1079}