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}