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}