Skip to main content

netget/server/arp/
mod.rs

1//! ARP (Address Resolution Protocol) server implementation
2//!
3//! This module provides functionality to capture and respond to ARP requests at the data link layer.
4//! It uses libpcap via pnet to interact with network interfaces and handle ARP packets.
5
6pub mod actions;
7
8use anyhow::{Context, Result};
9use pcap::{Capture, Device};
10use pnet::packet::arp::{ArpHardwareTypes, ArpOperations, ArpPacket, MutableArpPacket};
11use pnet::packet::ethernet::{EtherTypes, EthernetPacket, MutableEthernetPacket};
12use pnet::packet::Packet;
13use pnet::util::MacAddr;
14use std::net::Ipv4Addr;
15use std::sync::Arc;
16use tokio::sync::mpsc;
17use tracing::{debug, error, info, trace};
18
19use crate::llm::action_helper::call_llm;
20use crate::llm::ollama_client::OllamaClient;
21use crate::protocol::Event;
22use crate::state::app_state::AppState;
23use crate::{console_error, console_info, console_trace};
24use actions::{ArpProtocol, ARP_REQUEST_RECEIVED_EVENT};
25
26// `get_llm_protocol_prompt()` used to live here, and it was worse than dead. Nothing called
27// it (`grep -rn get_llm_protocol_prompt src/ tests/`), and the output format it prescribed —
28// `{"output": "Ethernet frame containing ARP reply as hex", "message": null}` — is not a shape
29// any executor in this tree parses. A model that had ever been shown it would have answered
30// with something `execute_action` refuses, and the peer would have got the silence this file
31// spends so much care distinguishing from a real decision. The model's actual prompt is built
32// from the action definitions in `actions.rs`.
33//
34// Deleted rather than corrected: a second, unreferenced description of a protocol is a thing
35// to drift, not a thing to maintain. `datalink` carried the identical function with the
36// identical falsehood and it went the same way in the same pass.
37
38/// ARP server that captures and responds to ARP requests
39pub struct ArpServer;
40
41impl ArpServer {
42    /// List available network interfaces
43    pub fn list_devices() -> Result<Vec<Device>> {
44        Device::list().context("Failed to list network devices")
45    }
46
47    /// Find a device by name
48    pub fn find_device(name: &str) -> Result<Device> {
49        let devices = Self::list_devices()?;
50        devices
51            .into_iter()
52            .find(|d| d.name == name)
53            .ok_or_else(|| anyhow::anyhow!("Device '{}' not found", name))
54    }
55
56    /// Spawn ARP server with integrated LLM handling (async wrapper for blocking pcap)
57    pub async fn spawn_with_llm(
58        interface: String,
59        llm_client: OllamaClient,
60        app_state: Arc<AppState>,
61        status_tx: mpsc::UnboundedSender<String>,
62        server_id: crate::state::ServerId,
63    ) -> Result<String> {
64        console_info!(
65            status_tx,
66            "Starting ARP capture on interface: {}",
67            interface
68        );
69
70        // Retained by this function; the capture task takes ownership of `status_tx`.
71        let status_tx_ready = status_tx.clone();
72
73        let protocol = Arc::new(ArpProtocol::new());
74
75        // ARP/pcap is blocking, so we run it in a blocking task.
76        //
77        // Opening the pcap handle is the step that requires privileges (root, or read/write
78        // access to /dev/bpf* on macOS and the BSDs, or CAP_NET_RAW on Linux). It therefore
79        // MUST NOT be fire-and-forget: we hand the outcome back over a oneshot and only return
80        // Ok once the capture is genuinely live, so a failure surfaces as ServerStatus::Error
81        // instead of a server that reports Running while capturing nothing.
82        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<Result<()>>();
83
84        // `JoinHandle::abort()` cannot interrupt a thread parked in `next_packet()`, so the
85        // capture loop is stopped cooperatively: it polls this flag every iteration, and the
86        // task registered with `register_server_task` below trips it when `stop_server` aborts
87        // it. Without this the capture kept running (and kept calling the LLM) after the server
88        // was stopped, until the process exited. See `crate::utils::shutdown`.
89        let stop = crate::utils::StopSignal::new();
90        let stop_in_loop = stop.clone();
91        // Retained by this function; the capture task takes ownership of `app_state`.
92        let app_state_reg = app_state.clone();
93
94        let interface_clone = interface.clone();
95        let protocol_clone = protocol.clone();
96        tokio::task::spawn_blocking(move || {
97            let open_captures = || -> Result<(Capture<pcap::Active>, Capture<pcap::Active>)> {
98                let device = Self::find_device(&interface_clone)
99                    .with_context(|| format!("no such capture device '{}'", interface_clone))?;
100
101                // Open capture for receiving
102                let mut cap_rx = Capture::from_device(device.clone())
103                    .map(|c| c.promisc(true).snaplen(65535).timeout(1000))
104                    .and_then(|c| c.open())
105                    .with_context(|| {
106                        format!(
107                            "failed to open pcap capture on '{}' (needs root, or \
108                             read access to /dev/bpf* on macOS/BSD, or CAP_NET_RAW on Linux)",
109                            interface_clone
110                        )
111                    })?;
112
113                // Apply ARP filter to receiving capture.
114                //
115                // Without the filter the capture hands *every* frame on the segment to the
116                // LLM, so a failure here has to refuse the start rather than fall through.
117                //
118                // The common failure is not a broken expression: `arp` is an Ethernet-only
119                // BPF keyword, and on a link type that cannot carry ARP at all — loopback
120                // (DLT_NULL/DLT_LOOP), a tunnel, a raw-IP device — libpcap compiles it to
121                // "expression rejects all packets" and returns an error. That message names
122                // the optimiser, not the problem, and an operator who asked for ARP on `lo0`
123                // deserves to be told that loopback has no link layer for ARP to live on.
124                // Same trap as `isis`, which `src/tui/wireshark.rs` already documents.
125                cap_rx.filter("arp", true).with_context(|| {
126                    format!(
127                        "failed to apply the 'arp' BPF filter on '{}'. ARP is an Ethernet-only \
128                         protocol, so this fails on any interface with no Ethernet link layer — \
129                         loopback (lo/lo0), tunnels and raw-IP devices carry no ARP and libpcap \
130                         rejects the filter outright. Point this server at a real Ethernet or \
131                         Wi-Fi interface.",
132                        interface_clone
133                    )
134                })?;
135
136                // Open capture for sending (separate instance)
137                let cap_tx = Capture::from_device(device)
138                    .map(|c| c.promisc(true).snaplen(65535).timeout(1000))
139                    .and_then(|c| c.open())
140                    .with_context(|| {
141                        format!(
142                            "failed to open pcap injection handle on '{}'",
143                            interface_clone
144                        )
145                    })?;
146
147                Ok((cap_rx, cap_tx))
148            };
149
150            let (mut cap_rx, mut cap_tx) = match open_captures() {
151                Ok(handles) => {
152                    let _ = ready_tx.send(Ok(()));
153                    handles
154                }
155                Err(e) => {
156                    console_error!(status_tx, "ARP capture startup failed: {:#}", e);
157                    let _ = ready_tx.send(Err(e));
158                    return;
159                }
160            };
161
162            let runtime = tokio::runtime::Handle::current();
163
164            // Channel for sending packets from async tasks back to blocking thread
165            let (packet_tx, packet_rx) = std::sync::mpsc::channel::<Vec<u8>>();
166
167            // Spawn a task to handle packet injection
168            std::thread::spawn(move || {
169                while let Ok(packet) = packet_rx.recv() {
170                    // This will block, but that's OK - we're in a dedicated thread
171                    if let Err(e) = cap_tx.sendpacket(packet) {
172                        error!("Failed to send ARP packet: {}", e);
173                    }
174                }
175            });
176
177            // Capture loop. The pcap read timeout (1000ms, set above) is what bounds how long
178            // a stop takes to be noticed on an idle interface.
179            loop {
180                if stop_in_loop.is_stopped() {
181                    console_info!(status_tx, "ARP capture on {} stopping", interface_clone);
182                    break;
183                }
184                match cap_rx.next_packet() {
185                    Ok(packet) => {
186                        let data = packet.data.to_vec();
187
188                        // Parse Ethernet frame
189                        let eth_packet = match EthernetPacket::new(&data) {
190                            Some(p) => p,
191                            None => {
192                                debug!("Failed to parse Ethernet packet");
193                                continue;
194                            }
195                        };
196
197                        // Check if it's an ARP packet
198                        if eth_packet.get_ethertype() != EtherTypes::Arp {
199                            continue;
200                        }
201
202                        // Parse ARP packet
203                        let arp_packet = match ArpPacket::new(eth_packet.payload()) {
204                            Some(p) => p,
205                            None => {
206                                debug!("Failed to parse ARP packet");
207                                continue;
208                            }
209                        };
210
211                        // Extract ARP information
212                        let operation = arp_packet.get_operation();
213                        let sender_mac = arp_packet.get_sender_hw_addr();
214                        let sender_ip = arp_packet.get_sender_proto_addr();
215                        let target_mac = arp_packet.get_target_hw_addr();
216                        let target_ip = arp_packet.get_target_proto_addr();
217
218                        // DEBUG: Log summary
219                        debug!(
220                            "ARP {} from {} ({}) for {} ({})",
221                            operation_to_string(operation),
222                            sender_mac,
223                            sender_ip,
224                            target_mac,
225                            target_ip
226                        );
227                        let _ = status_tx.send(format!(
228                            "[DEBUG] ARP {} from {} ({}) for {} ({})",
229                            operation_to_string(operation),
230                            sender_mac,
231                            sender_ip,
232                            target_mac,
233                            target_ip
234                        ));
235
236                        // TRACE: Log full packet
237                        let hex_str = hex::encode(&data);
238                        console_trace!(status_tx, "ARP packet (hex): {}", hex_str);
239
240                        let llm_clone = llm_client.clone();
241                        let state_clone = app_state.clone();
242                        let status_clone = status_tx.clone();
243                        let protocol_task_clone = protocol_clone.clone();
244                        let packet_tx_clone = packet_tx.clone();
245
246                        // Spawn async task to handle packet with LLM
247                        runtime.spawn(async move {
248                            // Build event data
249                            let event = Event::new(
250                                &ARP_REQUEST_RECEIVED_EVENT,
251                                serde_json::json!({
252                                    "operation": operation_to_string(operation),
253                                    "sender_mac": sender_mac.to_string(),
254                                    "sender_ip": sender_ip.to_string(),
255                                    "target_mac": target_mac.to_string(),
256                                    "target_ip": target_ip.to_string(),
257                                    "packet_hex": hex::encode(&data)
258                                }),
259                            );
260
261                            // An ARP reply is NOT wire-determined: the MAC advertised as owning
262                            // the queried IP is a chosen answer (the whole point is deciding
263                            // which MAC to claim — spoofing, honeypot, custom mapping), exactly
264                            // the kind of policy DNS/DHCP leave to the model. There is no
265                            // mechanical reply to synthesise from the request. So with no operator
266                            // policy — no server instruction and no per-event handler — the
267                            // spec-safe default is to answer nothing (we have no MAC to claim),
268                            // WITHOUT burning an LLM round-trip per captured packet. The model is
269                            // consulted only when the operator opts in with the mapping to serve.
270                            if !operator_wants_dynamic(
271                                &state_clone,
272                                server_id,
273                                &event.event_type.id,
274                            )
275                            .await
276                            {
277                                debug!(
278                                    "ARP decision=no_policy: ignoring {} packet, no operator policy configured (no instruction or handler), no MAC to advertise and no LLM call",
279                                    operation_to_string(operation)
280                                );
281                                let _ = status_clone.send(format!(
282                                    "ARP decision=no_policy: {} ignored, no policy configured (static default, no LLM)",
283                                    operation_to_string(operation)
284                                ));
285                                return;
286                            }
287
288                            debug!(
289                                "ARP calling LLM for {} packet",
290                                operation_to_string(operation)
291                            );
292                            let _ = status_clone.send(format!(
293                                "[DEBUG] ARP calling LLM for {} packet",
294                                operation_to_string(operation)
295                            ));
296
297                            match call_llm(
298                                &llm_clone,
299                                &state_clone,
300                                server_id,
301                                None,
302                                &event,
303                                protocol_task_clone.as_ref(),
304                            )
305                            .await
306                            {
307                                Ok(execution_result) => {
308                                    for message in &execution_result.messages {
309                                        info!("{}", message);
310                                        let _ = status_clone.send(format!("[INFO] {}", message));
311                                    }
312
313                                    // Three outcomes have to stay apart in the log, because on
314                                    // the wire they are indistinguishable: ARP has no error
315                                    // message, so every one of them is silence. An operator
316                                    // reading `decision=` is the only way to tell a deliberate
317                                    // `ignore_arp` from a model that said nothing at all, and
318                                    // both from a backend that failed (logged in the Err arm).
319                                    let model_rejected = execution_result.raw_actions.iter().any(
320                                        |a| a.get("type").and_then(|t| t.as_str()) == Some("ignore_arp"),
321                                    );
322                                    if model_rejected {
323                                        info!(
324                                            "ARP decision=model_reject: model chose ignore_arp for {} from {} for {}, no reply sent",
325                                            operation_to_string(operation),
326                                            sender_ip,
327                                            target_ip
328                                        );
329                                        let _ = status_clone.send(format!(
330                                            "ARP decision=model_reject: ignore_arp for {} {} -> {} (no reply)",
331                                            operation_to_string(operation),
332                                            sender_ip,
333                                            target_ip
334                                        ));
335                                    } else if execution_result.raw_actions.is_empty() {
336                                        info!(
337                                            "ARP decision=model_no_answer: model returned no actions for {} from {} for {}, no reply sent",
338                                            operation_to_string(operation),
339                                            sender_ip,
340                                            target_ip
341                                        );
342                                        let _ = status_clone.send(format!(
343                                            "ARP decision=model_no_answer: no actions for {} {} -> {} (no reply)",
344                                            operation_to_string(operation),
345                                            sender_ip,
346                                            target_ip
347                                        ));
348                                    } else if execution_result.protocol_results.is_empty() {
349                                        // A fourth outcome, and the one that reads as a bug
350                                        // rather than a policy: the model answered with real
351                                        // actions and every one of them failed to execute — a
352                                        // MAC that does not parse, an IPv6 address where IPv4
353                                        // is required. On the wire this is silence again, and
354                                        // conflating it with `model_no_answer` sends the
355                                        // operator looking at the prompt when the fault is in
356                                        // the action's fields. ERROR, not INFO: the other
357                                        // three are deliberate, this one is not.
358                                        error!(
359                                            "ARP decision=fail_closed_action_error: {} action(s) from the model all failed to execute for {} from {} for {}, no reply sent",
360                                            execution_result.raw_actions.len(),
361                                            operation_to_string(operation),
362                                            sender_ip,
363                                            target_ip
364                                        );
365                                        let _ = status_clone.send(format!(
366                                            "✗ ARP decision=fail_closed_action_error: {} {} -> {} produced no frame (no reply)",
367                                            operation_to_string(operation),
368                                            sender_ip,
369                                            target_ip
370                                        ));
371                                    }
372
373                                    debug!(
374                                        "ARP got {} protocol results",
375                                        execution_result.protocol_results.len()
376                                    );
377                                    let _ = status_clone.send(format!(
378                                        "[DEBUG] ARP got {} protocol results",
379                                        execution_result.protocol_results.len()
380                                    ));
381
382                                    // Send ARP replies if any via channel
383                                    for protocol_result in execution_result.protocol_results {
384                                        if let Some(output_data) =
385                                            protocol_result.get_all_output().first()
386                                        {
387                                            // Send packet via channel to injection thread
388                                            if packet_tx_clone.send(output_data.clone()).is_ok() {
389                                                debug!(
390                                                    "ARP queued {} bytes for sending",
391                                                    output_data.len()
392                                                );
393                                                let _ = status_clone.send(format!(
394                                                    "[DEBUG] ARP queued {} bytes for sending",
395                                                    output_data.len()
396                                                ));
397
398                                                trace!(
399                                                    "ARP reply (hex): {}",
400                                                    hex::encode(output_data)
401                                                );
402                                                let _ = status_clone.send(format!(
403                                                    "[TRACE] ARP reply (hex): {}",
404                                                    hex::encode(output_data)
405                                                ));
406                                            } else {
407                                                error!("Failed to queue ARP reply");
408                                                let _ = status_clone.send(
409                                                    "[ERROR] Failed to queue ARP reply".to_string(),
410                                                );
411                                            }
412                                        }
413                                    }
414
415                                    let _ = status_clone.send(format!(
416                                        "→ ARP {} processed: {} -> {}",
417                                        operation_to_string(operation),
418                                        sender_ip,
419                                        target_ip
420                                    ));
421                                }
422                                Err(e) => {
423                                    // Fail closed, and closed for ARP means *silence*. There is
424                                    // no ARP error frame: the only thing this server could put
425                                    // on the wire is a reply claiming some MAC owns the queried
426                                    // IP, and the MAC is precisely what the failed call was
427                                    // supposed to decide. Inventing one would poison the
428                                    // requester's neighbour cache — far worse than the
429                                    // requester's own ARP timeout, which is the normal,
430                                    // spec-defined outcome for "nobody here owns that address".
431                                    // So the peer gets nothing, and the operator gets the error.
432                                    let category = crate::utils::WireFailure::classify(&e);
433                                    let decision = if category.is_overloaded() {
434                                        "fail_closed_overloaded"
435                                    } else {
436                                        "fail_closed_llm_error"
437                                    };
438                                    error!(
439                                        "ARP decision={}: LLM call failed for {} from {} for {}, no reply sent: {}",
440                                        decision,
441                                        operation_to_string(operation),
442                                        sender_ip,
443                                        target_ip,
444                                        e
445                                    );
446                                    let _ = status_clone.send(format!(
447                                        "✗ ARP decision={}: {} {} -> {} unanswered ({}): {}",
448                                        decision,
449                                        operation_to_string(operation),
450                                        sender_ip,
451                                        target_ip,
452                                        category.text(),
453                                        e
454                                    ));
455                                }
456                            }
457                        });
458                    }
459                    Err(pcap::Error::TimeoutExpired) => {
460                        // Normal timeout, continue
461                        continue;
462                    }
463                    Err(e) => {
464                        console_error!(status_tx, "Packet capture error: {}", e);
465                        break;
466                    }
467                }
468            }
469
470            // Dropping the last `packet_tx` makes the injection thread's `recv()` fail, so it
471            // exits and closes the second pcap handle with it. Explicit because the thread is
472            // detached and this channel is the only thing that can end it.
473            drop(packet_tx);
474            drop(cap_rx);
475        });
476
477        // Wait for the blocking task to report whether the capture actually came up.
478        match ready_rx.await {
479            Ok(Ok(())) => {}
480            Ok(Err(e)) => return Err(e),
481            Err(_) => {
482                return Err(anyhow::anyhow!(
483                    "ARP capture task on '{}' exited before signalling readiness",
484                    interface
485                ))
486            }
487        }
488
489        // Only now that the capture is genuinely live: `stop_server` aborts this parked task,
490        // which trips `stop` and ends the blocking loop above.
491        app_state_reg
492            .register_server_task(server_id, stop.park_task())
493            .await;
494
495        console_info!(status_tx_ready, "ARP capture active on {}", interface);
496
497        Ok(interface)
498    }
499
500    /// Helper function to build an ARP reply packet
501    pub fn build_arp_reply(
502        sender_mac: MacAddr,
503        sender_ip: Ipv4Addr,
504        target_mac: MacAddr,
505        target_ip: Ipv4Addr,
506    ) -> Vec<u8> {
507        // Ethernet header (14 bytes) + ARP packet (28 bytes) = 42 bytes
508        let mut eth_buffer = vec![0u8; 42];
509
510        // Build Ethernet frame
511        {
512            let mut eth_packet = MutableEthernetPacket::new(&mut eth_buffer).unwrap();
513            eth_packet.set_destination(target_mac);
514            eth_packet.set_source(sender_mac);
515            eth_packet.set_ethertype(EtherTypes::Arp);
516
517            // Build ARP packet
518            let mut arp_buffer = vec![0u8; 28];
519            {
520                let mut arp_packet = MutableArpPacket::new(&mut arp_buffer).unwrap();
521                arp_packet.set_hardware_type(ArpHardwareTypes::Ethernet);
522                arp_packet.set_protocol_type(EtherTypes::Ipv4);
523                arp_packet.set_hw_addr_len(6);
524                arp_packet.set_proto_addr_len(4);
525                arp_packet.set_operation(ArpOperations::Reply);
526                arp_packet.set_sender_hw_addr(sender_mac);
527                arp_packet.set_sender_proto_addr(sender_ip);
528                arp_packet.set_target_hw_addr(target_mac);
529                arp_packet.set_target_proto_addr(target_ip);
530            }
531
532            eth_packet.set_payload(&arp_buffer);
533        }
534
535        eth_buffer
536    }
537}
538
539/// Convert ARP operation to human-readable string
540fn operation_to_string(op: pnet::packet::arp::ArpOperation) -> &'static str {
541    match op {
542        ArpOperations::Request => "REQUEST",
543        ArpOperations::Reply => "REPLY",
544        _ => "UNKNOWN",
545    }
546}
547
548/// Returns true if the operator opted into dynamic (LLM- or handler-driven) responses for this
549/// server: either a non-empty server instruction was given, or an event handler is configured
550/// for `event_id`. When false the protocol applies its static default and never consults the
551/// model — for a policy protocol like ARP that default is to answer nothing, because with no
552/// configured mapping there is no MAC it can legitimately claim.
553async fn operator_wants_dynamic(
554    state: &AppState,
555    server_id: crate::state::ServerId,
556    event_id: &str,
557) -> bool {
558    state
559        .with_server_mut(server_id, |server| {
560            let has_instruction = !server.instruction.trim().is_empty();
561            let has_handler = server
562                .event_handler_config
563                .as_ref()
564                .map(|c| c.find_handler(event_id).is_some())
565                .unwrap_or(false);
566            has_instruction || has_handler
567        })
568        .await
569        .unwrap_or(false)
570}