Skip to main content

netget/client/arp/
mod.rs

1//! ARP client implementation
2//!
3//! This module provides functionality to send ARP requests and monitor ARP traffic
4//! using libpcap and pnet for packet construction and capture.
5
6pub mod actions;
7
8pub use actions::ArpClientProtocol;
9
10use anyhow::{Context, Result};
11use pcap::{Capture, Device};
12use pnet::packet::arp::{ArpHardwareTypes, ArpOperations, ArpPacket, MutableArpPacket};
13use pnet::packet::ethernet::{EtherTypes, EthernetPacket, MutableEthernetPacket};
14use pnet::packet::Packet;
15use pnet::util::MacAddr;
16use std::net::{Ipv4Addr, SocketAddr};
17use std::str::FromStr;
18use std::sync::Arc;
19use tokio::sync::{mpsc, Mutex};
20use tracing::{debug, error, info, trace};
21
22use crate::client::arp::actions::{ARP_CLIENT_RESPONSE_RECEIVED_EVENT, ARP_CLIENT_STARTED_EVENT};
23use crate::client::llm_budget::call_llm_for_client;
24use crate::llm::actions::client_trait::{Client, ClientActionResult};
25use crate::llm::ollama_client::OllamaClient;
26use crate::llm::ClientLlmResult;
27use crate::logging::emit::Log;
28use crate::protocol::Event;
29use crate::state::app_state::AppState;
30use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
31use crate::state::{AccessLogOwner, ClientId, ClientStatus};
32
33/// Connection state for LLM processing
34#[derive(Debug, Clone, PartialEq)]
35enum ConnectionState {
36    Idle,
37    Processing,
38}
39
40/// One Ethernet frame handed to the pcap injection thread.
41///
42/// libpcap is a blocking API and its send handle is owned by a dedicated OS thread, so
43/// nothing outside that thread can call `sendpacket`. `ack` is how an injected command
44/// learns whether the frame really went out: the injection thread reports the result of
45/// `sendpacket` back, which is what lets the command loop answer `Sent { bytes_sent }`
46/// truthfully instead of guessing.
47///
48/// Public so a test can stand in for the pcap injection thread: holding the receiver and
49/// answering `ack` exercises the whole injected-command surface — including the `Sent`
50/// outcome, which is otherwise reachable only under root.
51pub struct InjectedPacket {
52    pub frame: Vec<u8>,
53    pub ack: Option<tokio::sync::oneshot::Sender<std::result::Result<usize, String>>>,
54}
55
56/// How long an injected command waits for the pcap thread's acknowledgement.
57const INJECTION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
58
59/// Per-client data for LLM handling
60struct ClientData {
61    state: ConnectionState,
62    memory: String,
63}
64
65/// ARP client that captures and sends ARP packets
66pub struct ArpClient;
67
68impl ArpClient {
69    /// List available network interfaces
70    pub fn list_devices() -> Result<Vec<Device>> {
71        Device::list().context("Failed to list network devices")
72    }
73
74    /// Find a device by name
75    pub fn find_device(name: &str) -> Result<Device> {
76        let devices = Self::list_devices()?;
77        devices
78            .into_iter()
79            .find(|d| d.name == name)
80            .ok_or_else(|| anyhow::anyhow!("Device '{}' not found", name))
81    }
82
83    /// Start ARP client with integrated LLM actions
84    pub async fn start_with_llm_actions(
85        interface: String,
86        llm_client: OllamaClient,
87        app_state: Arc<AppState>,
88        status_tx: mpsc::UnboundedSender<String>,
89        client_id: ClientId,
90    ) -> Result<SocketAddr> {
91        info!(
92            "ARP client {} starting on interface: {}",
93            client_id, interface
94        );
95        let _ = status_tx.send(format!(
96            "[CLIENT] ARP client {} starting on interface: {}",
97            client_id, interface
98        ));
99
100        // Update client state
101        app_state
102            .update_client_status(client_id, ClientStatus::Connected)
103            .await;
104        let _ = status_tx.send("__UPDATE_UI__".to_string());
105
106        // Initialize client data
107        let client_data = Arc::new(Mutex::new(ClientData {
108            state: ConnectionState::Idle,
109            memory: String::new(),
110        }));
111
112        // Get instruction for this client
113        let instruction = app_state
114            .get_instruction_for_client(client_id)
115            .await
116            .unwrap_or_else(|| "Monitor ARP traffic".to_string());
117
118        // Send started event to LLM
119        let protocol = Arc::new(ArpClientProtocol::new());
120        let event = Event::new(
121            &ARP_CLIENT_STARTED_EVENT,
122            serde_json::json!({
123                "interface": interface,
124            }),
125        );
126
127        // Channel to the pcap injection thread. Created here, not inside the blocking
128        // task, so the command loop can reach the send handle too - the LLM path and an
129        // injected command hand frames to the same thread through the same queue.
130        let (packet_tx, packet_rx) = mpsc::unbounded_channel::<InjectedPacket>();
131
132        // Command channel for injected actions (the dashboard's [ send ]).
133        // Registered BEFORE the started-event LLM call: a manual `*` routing rule can park
134        // that call for minutes, and the operator must be able to reach the client while it
135        // waits.
136        let command_rx =
137            crate::client::command_support::register_command_channel(&app_state, client_id).await;
138        let cmd_task = tokio::spawn(Self::command_loop(
139            command_rx,
140            protocol.clone(),
141            packet_tx.clone(),
142            client_id,
143            app_state.clone(),
144            status_tx.clone(),
145        ));
146        app_state.register_client_task(client_id, cmd_task).await;
147
148        // Copy the memory out and drop the guard BEFORE the call. A `client_data.lock().await`
149        // written inline in the scrutinee is a temporary whose guard lives until the end of the
150        // whole `match` — so the arm's own `client_data.lock().await.memory = mem` would wait
151        // forever on a mutex this task already holds. `tokio::sync::Mutex` is not reentrant and
152        // never times out, so the symptom is a client stuck in `Connected` for good.
153        let memory_snapshot = client_data.lock().await.memory.clone();
154
155        // Call LLM with started event
156        match call_llm_for_client(
157            &llm_client,
158            &app_state,
159            client_id.to_string(),
160            &instruction,
161            &memory_snapshot,
162            Some(&event),
163            protocol.as_ref(),
164            &status_tx,
165        )
166        .await
167        {
168            Ok(ClientLlmResult {
169                actions,
170                memory_updates,
171            }) => {
172                // Update memory
173                if let Some(mem) = memory_updates {
174                    client_data.lock().await.memory = mem;
175                }
176
177                // Execute the model's answer to the started event. Counting the actions and
178                // logging the count is the family's documented defect: a client told to send a
179                // gratuitous ARP announcement on start put nothing on the wire and reported
180                // success. The frames go to the same injection thread the packet-driven path
181                // uses, so both routes behave identically.
182                Self::execute_client_actions(
183                    actions,
184                    protocol.as_ref(),
185                    &packet_tx,
186                    client_id,
187                    &app_state,
188                    &status_tx,
189                )
190                .await;
191            }
192            Err(e) => {
193                error!("ARP client {} initial LLM call failed: {}", client_id, e);
194            }
195        }
196
197        // Spawn blocking task for packet capture (pcap is blocking)
198        let interface_clone = interface.clone();
199        let protocol_clone = protocol.clone();
200        let app_state_clone = app_state.clone();
201        let status_tx_clone = status_tx.clone();
202        let llm_client_clone = llm_client.clone();
203        let client_data_clone = client_data.clone();
204
205        // Opening the pcap handles is the step that needs privilege (root, /dev/bpf* on
206        // macOS/BSD, CAP_NET_RAW on Linux). It must NOT be fire-and-forget: the outcome comes
207        // back over this oneshot and `connect()` returns `Err`, so an unprivileged run shows
208        // `ClientStatus::Error` instead of a client sitting in `Connected` having captured
209        // nothing. This mirrors the fix already made on the ARP *server* side.
210        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<Result<()>>();
211
212        // `JoinHandle::abort()` cannot interrupt a thread parked in `next_packet()`, so the
213        // capture loop stops cooperatively on this flag. The parked task registered below
214        // trips it when `remove_client` aborts the client's tasks; without it the capture
215        // (and its LLM calls) outlived the client until the process exited.
216        let stop = crate::utils::StopSignal::new();
217        let stop_in_loop = stop.clone();
218
219        tokio::task::spawn_blocking(move || {
220            let open_captures = || -> Result<(Capture<pcap::Active>, Capture<pcap::Active>)> {
221                let device = Self::find_device(&interface_clone)
222                    .with_context(|| format!("no such capture device '{}'", interface_clone))?;
223
224                let mut cap_rx = Capture::from_device(device.clone())
225                    .map(|c| c.promisc(true).snaplen(65535).timeout(1000))
226                    .and_then(|c| c.open())
227                    .with_context(|| {
228                        format!(
229                            "failed to open pcap capture on '{}' (needs root, or read \
230                                 access to /dev/bpf* on macOS/BSD, or CAP_NET_RAW on Linux)",
231                            interface_clone
232                        )
233                    })?;
234
235                cap_rx
236                    .filter("arp", true)
237                    .context("failed to apply the 'arp' BPF filter")?;
238
239                let cap_tx = Capture::from_device(device)
240                    .map(|c| c.promisc(true).snaplen(65535).timeout(1000))
241                    .and_then(|c| c.open())
242                    .with_context(|| {
243                        format!(
244                            "failed to open pcap injection handle on '{}'",
245                            interface_clone
246                        )
247                    })?;
248
249                Ok((cap_rx, cap_tx))
250            };
251
252            let (mut cap_rx, mut cap_tx) = match open_captures() {
253                Ok(handles) => {
254                    let _ = ready_tx.send(Ok(()));
255                    handles
256                }
257                Err(e) => {
258                    Log::new(Some(&status_tx_clone))
259                        .error(format!("ARP client capture startup failed: {:#}", e));
260                    let _ = ready_tx.send(Err(e));
261                    return;
262                }
263            };
264
265            let runtime = tokio::runtime::Handle::current();
266
267            // Spawn a dedicated OS thread to handle packet injection. It owns `cap_tx`;
268            // `blocking_recv` is legal here because a plain `std::thread` is outside the
269            // runtime's async context.
270            let mut packet_rx = packet_rx;
271            std::thread::spawn(move || {
272                while let Some(packet) = packet_rx.blocking_recv() {
273                    // This will block, but that's OK - we're in a dedicated thread
274                    let len = packet.frame.len();
275                    let result = match cap_tx.sendpacket(&packet.frame[..]) {
276                        Ok(()) => Ok(len),
277                        Err(e) => {
278                            error!("Failed to send ARP packet: {}", e);
279                            Err(e.to_string())
280                        }
281                    };
282                    if let Some(ack) = packet.ack {
283                        let _ = ack.send(result);
284                    }
285                }
286            });
287
288            // Capture loop. The pcap read timeout (1000ms, set above) bounds how long a stop
289            // takes to be noticed on an idle interface.
290            loop {
291                if stop_in_loop.is_stopped() {
292                    info!(
293                        "ARP client {} capture on {} stopping",
294                        client_id, interface_clone
295                    );
296                    break;
297                }
298                match cap_rx.next_packet() {
299                    Ok(packet) => {
300                        let data = packet.data.to_vec();
301
302                        // Parse Ethernet frame
303                        let eth_packet = match EthernetPacket::new(&data) {
304                            Some(p) => p,
305                            None => {
306                                debug!("Failed to parse Ethernet packet");
307                                continue;
308                            }
309                        };
310
311                        // Check if it's an ARP packet
312                        if eth_packet.get_ethertype() != EtherTypes::Arp {
313                            continue;
314                        }
315
316                        // Parse ARP packet
317                        let arp_packet = match ArpPacket::new(eth_packet.payload()) {
318                            Some(p) => p,
319                            None => {
320                                debug!("Failed to parse ARP packet");
321                                continue;
322                            }
323                        };
324
325                        // Extract ARP information
326                        let operation = arp_packet.get_operation();
327                        let sender_mac = arp_packet.get_sender_hw_addr();
328                        let sender_ip = arp_packet.get_sender_proto_addr();
329                        let target_mac = arp_packet.get_target_hw_addr();
330                        let target_ip = arp_packet.get_target_proto_addr();
331
332                        // DEBUG: Log summary
333                        Log::new(Some(&status_tx_clone)).debug(format!(
334                            "ARP client {} received {} from {} ({}) for {} ({})",
335                            client_id,
336                            operation_to_string(operation),
337                            sender_mac,
338                            sender_ip,
339                            target_mac,
340                            target_ip
341                        ));
342
343                        // TRACE: Log full packet
344                        let hex_str = hex::encode(&data);
345                        trace!("ARP packet (hex): {}", hex_str);
346
347                        let llm_clone = llm_client_clone.clone();
348                        let state_clone = app_state_clone.clone();
349                        let status_clone = status_tx_clone.clone();
350                        let protocol_task_clone = protocol_clone.clone();
351                        let packet_tx_clone = packet_tx.clone();
352                        let client_data_task_clone = client_data_clone.clone();
353
354                        // Spawn async task to handle packet with LLM
355                        runtime.spawn(async move {
356                            // Check state
357                            let mut client_data_lock = client_data_task_clone.lock().await;
358
359                            match client_data_lock.state {
360                                ConnectionState::Idle => {
361                                    // Process immediately
362                                    client_data_lock.state = ConnectionState::Processing;
363                                    let current_memory = client_data_lock.memory.clone();
364                                    drop(client_data_lock);
365
366                                    // Build event data
367                                    let event = Event::new(
368                                        &ARP_CLIENT_RESPONSE_RECEIVED_EVENT,
369                                        serde_json::json!({
370                                            "operation": operation_to_string(operation),
371                                            "sender_mac": sender_mac.to_string(),
372                                            "sender_ip": sender_ip.to_string(),
373                                            "target_mac": target_mac.to_string(),
374                                            "target_ip": target_ip.to_string(),
375                                        }),
376                                    );
377
378                                    debug!(
379                                        "ARP client {} calling LLM for {} packet",
380                                        client_id,
381                                        operation_to_string(operation)
382                                    );
383
384                                    // Get instruction for this client
385                                    let instruction = state_clone
386                                        .get_instruction_for_client(client_id)
387                                        .await
388                                        .unwrap_or_else(|| "Monitor ARP traffic".to_string());
389
390                                    match call_llm_for_client(
391                                        &llm_clone,
392                                        &state_clone,
393                                        client_id.to_string(),
394                                        &instruction,
395                                        &current_memory,
396                                        Some(&event),
397                                        protocol_task_clone.as_ref(),
398                                        &status_clone,
399                                    )
400                                    .await
401                                    {
402                                        Ok(ClientLlmResult {
403                                            actions,
404                                            memory_updates,
405                                        }) => {
406                                            // Update memory
407                                            if let Some(mem) = memory_updates {
408                                                client_data_task_clone.lock().await.memory = mem;
409                                            }
410
411                                            // Execute actions
412                                            if Self::execute_client_actions(
413                                                actions,
414                                                protocol_task_clone.as_ref(),
415                                                &packet_tx_clone,
416                                                client_id,
417                                                &state_clone,
418                                                &status_clone,
419                                            )
420                                            .await
421                                            {
422                                                return;
423                                            }
424                                        }
425                                        Err(e) => {
426                                            error!("LLM error for ARP client {}: {}", client_id, e);
427                                        }
428                                    }
429
430                                    // Set state back to idle
431                                    client_data_task_clone.lock().await.state =
432                                        ConnectionState::Idle;
433                                }
434                                ConnectionState::Processing => {
435                                    // Skip packet - already processing another one
436                                    debug!(
437                                        "ARP client {} is processing, skipping packet",
438                                        client_id
439                                    );
440                                }
441                            }
442                        });
443                    }
444                    Err(pcap::Error::TimeoutExpired) => {
445                        // Normal timeout, continue
446                        continue;
447                    }
448                    Err(e) => {
449                        Log::new(Some(&status_tx_clone)).error(format!(
450                            "ARP client {} packet capture error: {}",
451                            client_id, e
452                        ));
453                        break;
454                    }
455                }
456            }
457
458            // The capture is gone: drop the command handle so the dashboard stops offering
459            // [ send ] on a client that can no longer put anything on the wire. This also
460            // closes the command channel, which ends `command_loop`.
461            runtime.block_on(app_state_clone.remove_client_handle(client_id));
462            let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
463        });
464
465        // Wait for the blocking task to report whether the capture actually came up, and
466        // return `Err` if it did not.
467        //
468        // There is no third option: `client_startup::connect_client` overwrites the status
469        // with `ClientStatus::Connected` on any `Ok`, so a client that returns `Ok` having
470        // opened nothing *is* the lie this family is known for — the client-side twin of the
471        // fire-and-forget `spawn_blocking` that left ARP/DataLink/ICMP servers `Running` with
472        // no capture. Erroring out puts the reason (device name, privilege hint) in front of
473        // the operator, which the same generic path does via its `Err` arm. The ICMP client
474        // already behaves this way; ARP now matches it.
475        match ready_rx.await {
476            Ok(Ok(())) => {}
477            Ok(Err(e)) => {
478                app_state.remove_client_handle(client_id).await;
479                return Err(e);
480            }
481            Err(_) => {
482                app_state.remove_client_handle(client_id).await;
483                return Err(anyhow::anyhow!(
484                    "ARP capture task on '{}' exited before signalling readiness",
485                    interface
486                ));
487            }
488        }
489
490        // Only now that the capture is genuinely live: `remove_client` aborts this parked
491        // task, which trips `stop` and ends the blocking loop above.
492        app_state
493            .register_client_task(client_id, stop.park_task())
494            .await;
495
496        // Return a dummy socket address (ARP doesn't use ports)
497        Ok(SocketAddr::from(([0, 0, 0, 0], 0)))
498    }
499
500    /// Execute a batch of model-produced actions, handing any resulting frame to the pcap
501    /// injection thread.
502    ///
503    /// Both LLM entry points go through here — the started event and every captured packet —
504    /// so a client cannot act on one and silently drop the other. Returns `true` if the model
505    /// asked to disconnect, in which case the caller must stop.
506    async fn execute_client_actions(
507        actions: Vec<serde_json::Value>,
508        protocol: &ArpClientProtocol,
509        packet_tx: &mpsc::UnboundedSender<InjectedPacket>,
510        client_id: ClientId,
511        app_state: &Arc<AppState>,
512        status_tx: &mpsc::UnboundedSender<String>,
513    ) -> bool {
514        for action in actions {
515            match protocol.execute_action(action) {
516                Ok(ClientActionResult::Custom { name, data }) => {
517                    match build_packet_for_custom_result(&name, &data) {
518                        Some(packet) => {
519                            if packet_tx
520                                .send(InjectedPacket {
521                                    frame: packet,
522                                    ack: None,
523                                })
524                                .is_ok()
525                            {
526                                debug!("ARP client {} queued {}", client_id, name);
527                            } else {
528                                // The injection thread is gone: the capture never opened, or
529                                // it has stopped. Saying so is the difference between "sent"
530                                // and "silently dropped".
531                                error!(
532                                    "ARP client {} could not queue '{}': the pcap injection \
533                                     thread is not running",
534                                    client_id, name
535                                );
536                            }
537                        }
538                        None => error!(
539                            "ARP client {} could not build a frame for '{}': check the MAC and \
540                             IP fields",
541                            client_id, name
542                        ),
543                    }
544                }
545                Ok(ClientActionResult::Disconnect) => {
546                    info!("ARP client {} stopping capture", client_id);
547                    app_state
548                        .update_client_status(client_id, ClientStatus::Disconnected)
549                        .await;
550                    let _ = status_tx.send("__UPDATE_UI__".to_string());
551                    return true;
552                }
553                Ok(_) => {}
554                Err(e) => {
555                    error!("ARP client {} action failed: {}", client_id, e);
556                    let _ = status_tx.send(format!(
557                        "[WARN] ARP client {} action failed: {}",
558                        client_id, e
559                    ));
560                }
561            }
562        }
563        false
564    }
565
566    /// Drain injected commands until the channel closes (client removed, or the capture
567    /// thread exited and dropped the handle) or an injected `stop_capture` ends the session.
568    ///
569    /// The generic `command_support::handle_stream_client_command` cannot serve this client:
570    /// there is no `AsyncWrite` half at all. An ARP frame is built here exactly as the LLM
571    /// path builds it and handed to the same pcap injection thread, which acknowledges the
572    /// `sendpacket` call so the reply can be truthful.
573    pub async fn command_loop(
574        mut command_rx: mpsc::Receiver<ClientCommand>,
575        protocol: Arc<ArpClientProtocol>,
576        packet_tx: mpsc::UnboundedSender<InjectedPacket>,
577        client_id: ClientId,
578        app_state: Arc<AppState>,
579        status_tx: mpsc::UnboundedSender<String>,
580    ) {
581        use crate::llm::actions::protocol_trait::Protocol;
582
583        while let Some(command) = command_rx.recv().await {
584            let action = command.action.clone();
585            let outcome = Self::execute_injected_action(&protocol, &packet_tx, &action).await;
586
587            let outcome_json = match &outcome {
588                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
589                Err(e) => serde_json::json!({"error": e.to_string()}),
590            };
591            app_state
592                .record_access_log(
593                    AccessLogOwner::Client(client_id.as_u32()),
594                    protocol.protocol_name(),
595                    None,
596                    "injected_action",
597                    action,
598                    vec![outcome_json],
599                )
600                .await;
601
602            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
603            if let Err(e) = &outcome {
604                error!("ARP client {} injected action failed: {}", client_id, e);
605                let _ = status_tx.send(format!(
606                    "[WARN] Client {} injected action failed: {}",
607                    client_id, e
608                ));
609            }
610            let _ = status_tx.send("__UPDATE_UI__".to_string());
611            crate::client::command_support::reply(command, outcome);
612
613            if disconnect {
614                app_state.remove_client_handle(client_id).await;
615                app_state
616                    .update_client_status(client_id, ClientStatus::Disconnected)
617                    .await;
618                let _ = status_tx.send("__UPDATE_UI__".to_string());
619                break;
620            }
621        }
622    }
623
624    /// Execute one injected action and report exactly what happened to it.
625    ///
626    /// - `Sent { bytes_sent }` only after the pcap injection thread has acknowledged a
627    ///   successful `sendpacket` for that many bytes.
628    /// - `Executed { detail }` when the frame could not be handed over at all - which is
629    ///   what an unprivileged run looks like, because the capture never opened and the
630    ///   injection thread is not running.
631    /// - `Rejected { error }` for an action the protocol refuses, or whose MAC/IP fields
632    ///   are not parseable into a frame.
633    /// - `Err` when pcap accepted the frame and failed to transmit it.
634    async fn execute_injected_action(
635        protocol: &Arc<ArpClientProtocol>,
636        packet_tx: &mpsc::UnboundedSender<InjectedPacket>,
637        action: &serde_json::Value,
638    ) -> Result<ClientSendOutcome> {
639        let result = match protocol.as_ref().execute_action(action.clone()) {
640            Ok(result) => result,
641            Err(e) => {
642                return Ok(ClientSendOutcome::Rejected {
643                    error: e.to_string(),
644                })
645            }
646        };
647
648        let (name, data) = match result {
649            ClientActionResult::Custom { name, data } => (name, data),
650            ClientActionResult::Disconnect => return Ok(ClientSendOutcome::Disconnected),
651            ClientActionResult::WaitForMore => {
652                return Ok(ClientSendOutcome::Executed {
653                    detail: "wait_for_more".to_string(),
654                })
655            }
656            other => {
657                return Ok(ClientSendOutcome::Executed {
658                    detail: format!("{other:?} builds no ARP frame"),
659                })
660            }
661        };
662
663        let frame = match build_packet_for_custom_result(&name, &data) {
664            Some(frame) => frame,
665            None => {
666                return Ok(ClientSendOutcome::Rejected {
667                    error: format!(
668                        "could not build an ARP frame for '{name}': check the MAC and IP fields"
669                    ),
670                })
671            }
672        };
673
674        let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
675        if packet_tx
676            .send(InjectedPacket {
677                frame,
678                ack: Some(ack_tx),
679            })
680            .is_err()
681        {
682            // The blocking task returned before opening a capture (no such device, or -
683            // the usual case off-root - libpcap could not open the interface), so its
684            // injection thread never started and the receiver is gone.
685            return Ok(ClientSendOutcome::Executed {
686                detail: format!(
687                    "'{name}' frame built but not injected: the pcap injection thread is not \
688                     running (the capture failed to open - ARP capture and injection need \
689                     root / BPF access)"
690                ),
691            });
692        }
693
694        match tokio::time::timeout(INJECTION_ACK_TIMEOUT, ack_rx).await {
695            Ok(Ok(Ok(bytes_sent))) => Ok(ClientSendOutcome::Sent { bytes_sent }),
696            Ok(Ok(Err(e))) => Err(anyhow::anyhow!("pcap sendpacket failed: {e}")),
697            Ok(Err(_)) => Ok(ClientSendOutcome::Executed {
698                detail: format!(
699                    "'{name}' frame handed to the pcap injection thread, which exited \
700                     without reporting the result"
701                ),
702            }),
703            Err(_) => Ok(ClientSendOutcome::Executed {
704                detail: format!(
705                    "'{name}' frame queued, but the pcap injection thread did not \
706                     acknowledge it within {}s",
707                    INJECTION_ACK_TIMEOUT.as_secs()
708                ),
709            }),
710        }
711    }
712}
713
714/// Build the Ethernet frame for one of the ARP protocol's `Custom` results, or `None` when
715/// the action's MAC/IP fields do not parse. Shared by the LLM path and injected commands so
716/// the frame layout exists in one place.
717///
718/// Public because this — not `execute_action` — is where a malformed MAC or a non-IPv4
719/// address is actually refused: `execute_action` only checks that the fields are *present*.
720/// A test that wants to assert the rejection has to reach this layer.
721pub fn build_packet_for_custom_result(name: &str, data: &serde_json::Value) -> Option<Vec<u8>> {
722    match name {
723        "send_arp_request" => build_arp_request_from_action(data),
724        "send_arp_reply" => build_arp_reply_from_action(data),
725        _ => None,
726    }
727}
728
729/// Convert ARP operation to human-readable string
730fn operation_to_string(op: pnet::packet::arp::ArpOperation) -> &'static str {
731    match op {
732        ArpOperations::Request => "REQUEST",
733        ArpOperations::Reply => "REPLY",
734        _ => "UNKNOWN",
735    }
736}
737
738/// Build an ARP request packet from action data
739fn build_arp_request_from_action(data: &serde_json::Value) -> Option<Vec<u8>> {
740    let sender_mac = data["sender_mac"].as_str()?;
741    let sender_ip = data["sender_ip"].as_str()?;
742    let target_ip = data["target_ip"].as_str()?;
743
744    let sender_mac = MacAddr::from_str(sender_mac).ok()?;
745    let sender_ip = Ipv4Addr::from_str(sender_ip).ok()?;
746    let target_ip = Ipv4Addr::from_str(target_ip).ok()?;
747
748    Some(build_arp_request(sender_mac, sender_ip, target_ip))
749}
750
751/// Build an ARP reply packet from action data
752fn build_arp_reply_from_action(data: &serde_json::Value) -> Option<Vec<u8>> {
753    let sender_mac = data["sender_mac"].as_str()?;
754    let sender_ip = data["sender_ip"].as_str()?;
755    let target_mac = data["target_mac"].as_str()?;
756    let target_ip = data["target_ip"].as_str()?;
757
758    let sender_mac = MacAddr::from_str(sender_mac).ok()?;
759    let sender_ip = Ipv4Addr::from_str(sender_ip).ok()?;
760    let target_mac = MacAddr::from_str(target_mac).ok()?;
761    let target_ip = Ipv4Addr::from_str(target_ip).ok()?;
762
763    Some(build_arp_reply(
764        sender_mac, sender_ip, target_mac, target_ip,
765    ))
766}
767
768/// Helper function to build an ARP request packet
769fn build_arp_request(sender_mac: MacAddr, sender_ip: Ipv4Addr, target_ip: Ipv4Addr) -> Vec<u8> {
770    // Ethernet header (14 bytes) + ARP packet (28 bytes) = 42 bytes
771    let mut eth_buffer = vec![0u8; 42];
772
773    // Build Ethernet frame
774    {
775        let mut eth_packet = MutableEthernetPacket::new(&mut eth_buffer).unwrap();
776        eth_packet.set_destination(MacAddr::broadcast()); // Broadcast for ARP request
777        eth_packet.set_source(sender_mac);
778        eth_packet.set_ethertype(EtherTypes::Arp);
779
780        // Build ARP packet
781        let mut arp_buffer = vec![0u8; 28];
782        {
783            let mut arp_packet = MutableArpPacket::new(&mut arp_buffer).unwrap();
784            arp_packet.set_hardware_type(ArpHardwareTypes::Ethernet);
785            arp_packet.set_protocol_type(EtherTypes::Ipv4);
786            arp_packet.set_hw_addr_len(6);
787            arp_packet.set_proto_addr_len(4);
788            arp_packet.set_operation(ArpOperations::Request);
789            arp_packet.set_sender_hw_addr(sender_mac);
790            arp_packet.set_sender_proto_addr(sender_ip);
791            arp_packet.set_target_hw_addr(MacAddr::zero()); // Unknown for request
792            arp_packet.set_target_proto_addr(target_ip);
793        }
794
795        eth_packet.set_payload(&arp_buffer);
796    }
797
798    eth_buffer
799}
800
801/// Helper function to build an ARP reply packet
802fn build_arp_reply(
803    sender_mac: MacAddr,
804    sender_ip: Ipv4Addr,
805    target_mac: MacAddr,
806    target_ip: Ipv4Addr,
807) -> Vec<u8> {
808    // Ethernet header (14 bytes) + ARP packet (28 bytes) = 42 bytes
809    let mut eth_buffer = vec![0u8; 42];
810
811    // Build Ethernet frame
812    {
813        let mut eth_packet = MutableEthernetPacket::new(&mut eth_buffer).unwrap();
814        eth_packet.set_destination(target_mac);
815        eth_packet.set_source(sender_mac);
816        eth_packet.set_ethertype(EtherTypes::Arp);
817
818        // Build ARP packet
819        let mut arp_buffer = vec![0u8; 28];
820        {
821            let mut arp_packet = MutableArpPacket::new(&mut arp_buffer).unwrap();
822            arp_packet.set_hardware_type(ArpHardwareTypes::Ethernet);
823            arp_packet.set_protocol_type(EtherTypes::Ipv4);
824            arp_packet.set_hw_addr_len(6);
825            arp_packet.set_proto_addr_len(4);
826            arp_packet.set_operation(ArpOperations::Reply);
827            arp_packet.set_sender_hw_addr(sender_mac);
828            arp_packet.set_sender_proto_addr(sender_ip);
829            arp_packet.set_target_hw_addr(target_mac);
830            arp_packet.set_target_proto_addr(target_ip);
831        }
832
833        eth_packet.set_payload(&arp_buffer);
834    }
835
836    eth_buffer
837}