Skip to main content

netget/server/datalink/
mod.rs

1//! Data Link layer (Layer 2) server implementation using pcap
2//!
3//! This module provides functionality to capture packets at the data link layer.
4//! It uses libpcap to interact with network interfaces.
5//!
6//! # What the model is shown, and what it is not
7//!
8//! A captured frame is up to `snaplen` (65535) bytes. Handing all of that to the model as hex
9//! would be 131070 characters of prompt per packet — so `packet_hex` carries the first
10//! [`MAX_HEX_BYTES_TO_MODEL`] bytes and the event says, in `truncated` and `captured_length`,
11//! that it did. `packet_length` is always the real length.
12//!
13//! # Backpressure
14//!
15//! Every captured frame used to `runtime.spawn` an LLM task unconditionally. libpcap delivers
16//! frames as fast as the link does and an LLM turn takes seconds, so on any interface busier
17//! than the model is fast the spawned tasks — each holding a copy of the frame and its hex —
18//! accumulated without bound, all queued behind the same rate limiter. In-flight work is now
19//! capped by a semaphore ([`MAX_INFLIGHT_LLM_PACKETS`]) and frames arriving over that are
20//! dropped with a counted WARN. Dropping a frame is what a capture tool does under load;
21//! growing the heap until the process dies is not.
22
23pub mod actions;
24
25use anyhow::{Context, Result};
26use bytes::Bytes;
27use pcap::{Capture, Device};
28use std::sync::Arc;
29use tokio::sync::mpsc;
30use tracing::{debug, info, warn};
31
32use crate::llm::action_helper::call_llm;
33use crate::llm::ollama_client::OllamaClient;
34use crate::protocol::Event;
35use crate::state::app_state::AppState;
36use crate::utils::truncate_for_log;
37use crate::{console_debug, console_error, console_info, console_trace, console_warn};
38use actions::{DataLinkProtocol, DATALINK_PACKET_CAPTURED_EVENT};
39
40/// How many bytes of a captured frame are hex-encoded into the event handed to the model.
41///
42/// 2 KiB covers every layer-2 header plus a useful slice of payload and costs at most 4096
43/// prompt characters. The full length is always reported in `packet_length`.
44pub const MAX_HEX_BYTES_TO_MODEL: usize = 2048;
45
46/// How many bytes of frame hex reach the TRACE line / status stream.
47///
48/// The status channel is unbounded (see the repo CLAUDE.md), so a per-packet full hex dump is
49/// exactly the high-frequency message that must not go on it.
50const MAX_HEX_BYTES_TO_LOG: usize = 512;
51
52/// Maximum number of captured frames being handled by the LLM at once. Frames arriving while
53/// this many are outstanding are dropped rather than queued.
54const MAX_INFLIGHT_LLM_PACKETS: usize = 32;
55
56/// The event payload the model is given for one captured frame.
57///
58/// Pure, and public, so it can be asserted against literal frame bytes without a capture
59/// handle — the only part of this protocol that can be tested at all without BPF access.
60pub fn packet_event_data(frame: &[u8]) -> serde_json::Value {
61    let shown = frame.len().min(MAX_HEX_BYTES_TO_MODEL);
62    serde_json::json!({
63        "packet_length": frame.len(),
64        "packet_hex": hex::encode(&frame[..shown]),
65        "captured_length": shown,
66        "truncated": shown < frame.len(),
67    })
68}
69
70// `get_llm_protocol_prompt()` used to live here. It was called by nothing (`grep -rn
71// get_llm_protocol_prompt src/ tests/`) and what it told the model was false in both halves:
72// "You can capture and inject Ethernet frames … handle ARP requests/responses" of a
73// capture-only protocol, and an output format —
74// `{"output": "Ethernet frame data as hex (null if no response to inject)"}` — that is neither
75// the shape the executor parses nor a capability this protocol has. The model's real prompt
76// comes from the action definitions in `actions.rs`. Deleted rather than corrected: a second,
77// unreferenced description of the protocol is a thing to drift, not a thing to maintain.
78
79/// Data Link layer server that captures packets
80pub struct DataLinkServer;
81
82impl DataLinkServer {
83    /// List available network interfaces
84    pub fn list_devices() -> Result<Vec<Device>> {
85        Device::list().context("Failed to list network devices")
86    }
87
88    /// Find a device by name
89    pub fn find_device(name: &str) -> Result<Device> {
90        let devices = Self::list_devices()?;
91        devices
92            .into_iter()
93            .find(|d| d.name == name)
94            .ok_or_else(|| anyhow::anyhow!("Device '{}' not found", name))
95    }
96
97    /// Spawn datalink server with integrated LLM handling (async wrapper for blocking pcap)
98    pub async fn spawn_with_llm(
99        interface: String,
100        llm_client: OllamaClient,
101        app_state: Arc<AppState>,
102        status_tx: mpsc::UnboundedSender<String>,
103        filter: Option<String>,
104        server_id: crate::state::ServerId,
105    ) -> Result<String> {
106        console_info!(
107            status_tx,
108            "Starting packet capture on interface: {}",
109            interface
110        );
111
112        // Retained by this function; the capture task takes ownership of `status_tx`.
113        let status_tx_ready = status_tx.clone();
114
115        let protocol = Arc::new(DataLinkProtocol::new());
116
117        // Datalink/pcap is blocking, so we run it in a blocking task.
118        //
119        // Opening the pcap handle needs privileges (root, or read access to /dev/bpf* on
120        // macOS/BSD, or CAP_NET_RAW on Linux) and a valid BPF filter. Both are reported back
121        // over a oneshot so `spawn_with_llm` can return Err and the server is marked Error,
122        // rather than reporting Running while capturing nothing.
123        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<Result<()>>();
124
125        // `JoinHandle::abort()` cannot interrupt a thread parked in `next_packet()`, so the
126        // capture loop is stopped cooperatively: it polls this flag every iteration, and the
127        // task registered with `register_server_task` below trips it when `stop_server` aborts
128        // it. Without this the capture kept running (and kept calling the LLM) after the server
129        // was stopped, until the process exited. See `crate::utils::shutdown`.
130        let stop = crate::utils::StopSignal::new();
131        let stop_in_loop = stop.clone();
132        // Retained by this function; the capture task takes ownership of `app_state`.
133        let app_state_reg = app_state.clone();
134
135        // Bounds the number of frames being handled by the LLM at once; see the module docs.
136        let inflight = Arc::new(tokio::sync::Semaphore::new(MAX_INFLIGHT_LLM_PACKETS));
137
138        let interface_clone = interface.clone();
139        let protocol_clone = protocol.clone();
140        tokio::task::spawn_blocking(move || {
141            let open_capture = || -> Result<pcap::Capture<pcap::Active>> {
142                let device = Self::find_device(&interface_clone)
143                    .with_context(|| format!("no such capture device '{}'", interface_clone))?;
144
145                let mut cap = Capture::from_device(device)
146                    .map(|c| c.promisc(true).snaplen(65535).timeout(1000))
147                    .and_then(|c| c.open())
148                    .with_context(|| {
149                        format!(
150                            "failed to open pcap capture on '{}' (needs root, or \
151                             read access to /dev/bpf* on macOS/BSD, or CAP_NET_RAW on Linux)",
152                            interface_clone
153                        )
154                    })?;
155
156                // Apply filter if provided
157                if let Some(ref filter_str) = filter {
158                    cap.filter(filter_str, true)
159                        .with_context(|| format!("invalid BPF filter '{}'", filter_str))?;
160                }
161
162                Ok(cap)
163            };
164
165            let mut cap = match open_capture() {
166                Ok(cap) => {
167                    let _ = ready_tx.send(Ok(()));
168                    cap
169                }
170                Err(e) => {
171                    console_error!(status_tx, "DataLink capture startup failed: {:#}", e);
172                    let _ = ready_tx.send(Err(e));
173                    return;
174                }
175            };
176
177            let runtime = tokio::runtime::Handle::current();
178            let mut dropped: u64 = 0;
179
180            // Capture loop. The pcap read timeout (1000ms, set above) is what bounds how long
181            // a stop takes to be noticed on an idle interface.
182            loop {
183                if stop_in_loop.is_stopped() {
184                    console_info!(
185                        status_tx,
186                        "DataLink capture on {} stopping",
187                        interface_clone
188                    );
189                    break;
190                }
191                match cap.next_packet() {
192                    Ok(packet) => {
193                        let data = Bytes::copy_from_slice(packet.data);
194
195                        // DEBUG: Log summary
196                        console_debug!(status_tx, "Datalink received {} bytes", data.len());
197
198                        // TRACE: bounded hex preview. The status channel is unbounded, so the
199                        // full dump of every frame must not go on it.
200                        console_trace!(
201                            status_tx,
202                            "Datalink data (hex): {}",
203                            truncate_for_log(
204                                &hex::encode(&data),
205                                MAX_HEX_BYTES_TO_LOG.saturating_mul(2)
206                            )
207                        );
208
209                        // Backpressure: refuse to start another LLM turn when
210                        // MAX_INFLIGHT_LLM_PACKETS are already outstanding.
211                        let permit = match inflight.clone().try_acquire_owned() {
212                            Ok(p) => p,
213                            Err(_) => {
214                                dropped += 1;
215                                if dropped == 1 || dropped % 100 == 0 {
216                                    console_warn!(
217                                        status_tx,
218                                        "DataLink dropped {} captured frame(s) on {}: \
219                                         {} already awaiting the model. Narrow the BPF filter \
220                                         or use a script/static handler for high-rate capture.",
221                                        dropped,
222                                        interface_clone,
223                                        MAX_INFLIGHT_LLM_PACKETS
224                                    );
225                                }
226                                continue;
227                            }
228                        };
229
230                        let llm_clone = llm_client.clone();
231                        let state_clone = app_state.clone();
232                        let status_clone = status_tx.clone();
233                        let protocol_task_clone = protocol_clone.clone();
234
235                        // Spawn async task to handle packet with LLM
236                        runtime.spawn(async move {
237                            // Released when this turn ends, freeing a slot for the next frame.
238                            let _permit = permit;
239
240                            let full_len = data.len();
241                            let event = Event::new(
242                                &DATALINK_PACKET_CAPTURED_EVENT,
243                                packet_event_data(&data),
244                            );
245
246                            debug!("Datalink calling LLM for packet ({} bytes)", full_len);
247                            let _ = status_clone.send(format!(
248                                "[DEBUG] Datalink calling LLM for packet ({} bytes)",
249                                full_len
250                            ));
251
252                            match call_llm(
253                                &llm_clone,
254                                &state_clone,
255                                server_id,
256                                None,
257                                &event,
258                                protocol_task_clone.as_ref(),
259                            )
260                            .await
261                            {
262                                Ok(execution_result) => {
263                                    for message in &execution_result.messages {
264                                        info!("{}", message);
265                                        let _ = status_clone.send(format!("[INFO] {}", message));
266                                    }
267
268                                    // DataLink writes nothing to the wire in any case, so the
269                                    // three outcomes are indistinguishable on the interface and
270                                    // must be told apart in the log. Grep `decision=`.
271                                    let named_ignore =
272                                        execution_result.raw_actions.iter().any(|a| {
273                                            a.get("type").and_then(|v| v.as_str())
274                                                == Some("ignore_packet")
275                                        });
276                                    let decision = if named_ignore {
277                                        "model_ignore"
278                                    } else if execution_result.raw_actions.is_empty() {
279                                        "model_silent"
280                                    } else {
281                                        "model_analysed"
282                                    };
283                                    debug!(
284                                        "Datalink packet ({} bytes) decision={} ({} actions, \
285                                         {} protocol results)",
286                                        full_len,
287                                        decision,
288                                        execution_result.raw_actions.len(),
289                                        execution_result.protocol_results.len()
290                                    );
291                                    let _ = status_clone.send(format!(
292                                        "[DEBUG] Datalink packet decision={} ({} actions)",
293                                        decision,
294                                        execution_result.raw_actions.len()
295                                    ));
296
297                                    let _ = status_clone.send(format!(
298                                        "→ Datalink packet processed: {} bytes",
299                                        full_len
300                                    ));
301                                }
302                                Err(e) => {
303                                    // Nothing is written to the interface, because DataLink
304                                    // has nothing it *could* write: it is capture-only and
305                                    // declares no injection action. The failure is therefore
306                                    // reported to the operator only, with the category kept
307                                    // separate from the error text so a saturated backend
308                                    // (retry worthwhile) is distinguishable from a broken one.
309                                    let category = if crate::utils::WireFailure::classify(&e)
310                                        .is_overloaded()
311                                    {
312                                        "overloaded"
313                                    } else {
314                                        "unavailable"
315                                    };
316                                    warn!(
317                                        "Datalink packet ({} bytes) decision=fail_closed_llm_error \
318                                         category={}: {}",
319                                        full_len, category, e
320                                    );
321                                    let _ = status_clone.send(format!(
322                                        "✗ Datalink packet decision=fail_closed_llm_error \
323                                         category={}: {}",
324                                        category, e
325                                    ));
326                                }
327                            }
328                        });
329                    }
330                    Err(pcap::Error::TimeoutExpired) => {
331                        // Normal timeout, continue
332                        continue;
333                    }
334                    Err(e) => {
335                        console_error!(status_tx, "Packet capture error: {}", e);
336                        break;
337                    }
338                }
339            }
340
341            if dropped > 0 {
342                console_warn!(
343                    status_tx,
344                    "DataLink capture on {} ended having dropped {} frame(s) to backpressure",
345                    interface_clone,
346                    dropped
347                );
348            }
349
350            drop(cap);
351        });
352
353        // Wait for the blocking task to report whether the capture actually came up.
354        match ready_rx.await {
355            Ok(Ok(())) => {}
356            Ok(Err(e)) => return Err(e),
357            Err(_) => {
358                return Err(anyhow::anyhow!(
359                    "DataLink capture task on '{}' exited before signalling readiness",
360                    interface
361                ))
362            }
363        }
364
365        // Only now that the capture is genuinely live: `stop_server` aborts this parked task,
366        // which trips `stop` and ends the blocking loop above.
367        app_state_reg
368            .register_server_task(server_id, stop.park_task())
369            .await;
370
371        console_info!(status_tx_ready, "DataLink capture active on {}", interface);
372
373        Ok(interface)
374    }
375}