netget/client/datalink/mod.rs
1//! DataLink client implementation for raw Ethernet frame injection
2//!
3//! # Lifecycle
4//!
5//! `connect_with_llm_actions` **awaits readiness**: the blocking pcap task reports over a
6//! oneshot whether it found the device and opened a capture handle, and `connect` returns
7//! `Err` when it did not. Without that the client reported `Connected` on a host with no BPF
8//! access, having opened nothing — the family bug (`arp`, `datalink`, `icmp`, `isis` all had
9//! it on the server side, where it is already fixed).
10//!
11//! # Shutdown
12//!
13//! `JoinHandle::abort()` cannot interrupt a thread parked in `pcap::next_packet()`, so the
14//! blocking loop is stopped cooperatively through [`crate::utils::StopSignal`], registered
15//! with `register_client_task` exactly as the server side does. `remove_client` aborts that
16//! parked task, the guard trips the flag, and the loop exits at its next poll (≤10ms in
17//! injection-only mode, ≤100ms while parked in `next_packet`). Before this the loop ran
18//! forever: it held the capture handle after the client was gone, and — measurably — hung any
19//! process that dropped its runtime while a capture was open. `injected_frame_is_transmitted`
20//! did not fail, it hung the whole test binary in `BlockingPool::shutdown`.
21//!
22//! # Bounds
23//!
24//! Two things here would otherwise be unbounded, and both are cheap to bound:
25//!
26//! * **The inject → report → inject chain.** A frame that goes out raises
27//! `datalink_frame_injected`, which asks the model, which may inject again. That is a real
28//! cycle with an LLM call per hop, so [`InjectionCommand::llm_depth`] caps it at
29//! [`MAX_INJECTION_FOLLOWUPS`] — the `MAX_FOLLOWUP_DEPTH` pattern the repo prescribes,
30//! carried through the queue rather than the stack. A frame the operator injects from the
31//! dashboard always starts a fresh chain at depth 0.
32//! * **Captured frames arriving while the model is busy.** They used to be pushed onto a
33//! `Vec` that was only ever `clear()`ed, so a busy interface grew the heap by up to 64 KiB
34//! per frame to no purpose whatever. They are counted and dropped.
35
36pub mod actions;
37
38pub use actions::DataLinkClientProtocol;
39
40use anyhow::{Context, Result};
41use pcap::{Capture, Device};
42use std::net::SocketAddr;
43use std::sync::Arc;
44use tokio::sync::{mpsc, Mutex};
45use tracing::{error, info, trace, warn};
46
47use crate::client::datalink::actions::{
48 DATALINK_CLIENT_CONNECTED_EVENT, DATALINK_CLIENT_FRAME_CAPTURED_EVENT,
49 DATALINK_CLIENT_FRAME_INJECTED_EVENT,
50};
51use crate::client::llm_budget::call_llm_for_client;
52use crate::llm::ollama_client::OllamaClient;
53use crate::llm::ClientLlmResult;
54use crate::protocol::{Event, StartupParams};
55use crate::state::app_state::AppState;
56use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
57use crate::state::{AccessLogOwner, ClientId, ClientStatus};
58use crate::utils::StopSignal;
59
60/// Connection state for LLM processing
61#[derive(Debug, Clone, Copy, PartialEq)]
62enum ConnectionState {
63 Idle,
64 Processing,
65}
66
67/// Per-client capture bookkeeping.
68///
69/// This exists to stop two LLM turns running for one client at once. It used to also hold a
70/// `Vec<Vec<u8>>` of frames captured while busy and a second copy of the client's memory; the
71/// queue was never drained (only `clear()`ed) and the memory copy diverged from the one in
72/// `AppState`, which is the one every other path reads.
73struct ClientData {
74 state: ConnectionState,
75 /// Frames dropped because a turn was already running. Reported, not accumulated.
76 dropped_while_busy: u64,
77}
78
79/// Channel for sending frame injection commands to the pcap thread.
80///
81/// libpcap is a blocking API and its handle lives on the blocking task, so nothing else can
82/// call `sendpacket`. `ack` is how an injected command learns whether the frame really went
83/// out: the pcap loop reports the result back, which is what lets the command loop answer
84/// `Sent { bytes_sent }` truthfully instead of guessing.
85struct InjectionCommand {
86 frame: Vec<u8>,
87 ack: Option<tokio::sync::oneshot::Sender<std::result::Result<usize, String>>>,
88 /// How many LLM turns deep into an inject → report → inject chain this frame is.
89 ///
90 /// 0 for anything the operator or the connected event started. The pcap loop only raises
91 /// `datalink_frame_injected` while this is below [`MAX_INJECTION_FOLLOWUPS`], so a model
92 /// that answers every injection with another injection stops instead of spending the
93 /// LLM budget until something else kills it.
94 llm_depth: u8,
95}
96
97/// How long an injected command waits for the pcap loop's acknowledgement.
98const INJECTION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
99
100/// Maximum number of `inject → datalink_frame_injected → inject` hops in one chain.
101const MAX_INJECTION_FOLLOWUPS: u8 = 4;
102
103/// How many bytes of a captured or injected frame are hex-encoded into an event.
104///
105/// A frame can be 65535 bytes; all of it as hex is 131070 characters of prompt per event. The
106/// true length is always in `frame_length`.
107pub const MAX_HEX_BYTES_TO_MODEL: usize = 2048;
108
109/// The event payload the model is given for one frame: the Ethernet header read out as
110/// fields, the payload with an explicit `payload_encoding`, and the whole frame as hex for
111/// exact replay — all of it cut at [`MAX_HEX_BYTES_TO_MODEL`] bytes, with the fields that
112/// say so.
113///
114/// The header fields exist because the event used to carry `frame_hex` and nothing else, so a
115/// model that wanted to know who a frame was addressed to had to slice hex in its head. They
116/// are the same fields `inject_frame` accepts, in the same spellings, so answering a captured
117/// frame is a matter of copying them rather than re-deriving them.
118///
119/// `dst_mac`, `src_mac` and `ethertype` are `null` for a frame shorter than 14 bytes. A real
120/// Ethernet frame cannot be, but libpcap can hand back a runt and inventing a header for one
121/// would be worse than saying there is none.
122///
123/// Pure, and public, so it can be asserted against literal frame bytes with no capture handle.
124pub fn frame_event_fields(frame: &[u8]) -> serde_json::Value {
125 use crate::client::datalink::actions::{format_mac, MIN_ETHERNET_FRAME_BYTES};
126
127 let shown = frame.len().min(MAX_HEX_BYTES_TO_MODEL);
128 let visible = &frame[..shown];
129
130 let header = (visible.len() >= MIN_ETHERNET_FRAME_BYTES).then(|| {
131 (
132 format_mac(&visible[0..6]),
133 format_mac(&visible[6..12]),
134 format!("0x{:04x}", u16::from_be_bytes([visible[12], visible[13]])),
135 )
136 });
137 let payload = header
138 .as_ref()
139 .map(|_| &visible[MIN_ETHERNET_FRAME_BYTES..])
140 .unwrap_or(&[]);
141
142 // Printable ASCII reaches the model as itself; anything else is hex and `payload_encoding`
143 // says which. Handing the pair straight back to `inject_frame` reproduces these bytes.
144 let (payload_text, payload_encoding) = if payload
145 .iter()
146 .all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
147 {
148 (String::from_utf8_lossy(payload).to_string(), "utf8")
149 } else {
150 (hex::encode(payload), "hex")
151 };
152
153 serde_json::json!({
154 "frame_hex": hex::encode(visible),
155 "frame_length": frame.len(),
156 "captured_length": shown,
157 "truncated": shown < frame.len(),
158 "dst_mac": header.as_ref().map(|h| h.0.clone()),
159 "src_mac": header.as_ref().map(|h| h.1.clone()),
160 "ethertype": header.as_ref().map(|h| h.2.clone()),
161 "payload": payload_text,
162 "payload_encoding": payload_encoding,
163 })
164}
165
166/// DataLink client that injects raw Ethernet frames
167pub struct DataLinkClient;
168
169impl DataLinkClient {
170 /// Connect to a network interface for frame injection with integrated LLM actions
171 pub async fn connect_with_llm_actions(
172 _remote_addr: String, // Not used for DataLink (interface instead)
173 llm_client: OllamaClient,
174 app_state: Arc<AppState>,
175 status_tx: mpsc::UnboundedSender<String>,
176 client_id: ClientId,
177 startup_params: Option<StartupParams>,
178 ) -> Result<SocketAddr> {
179 // Extract interface from startup_params
180 let params = startup_params.as_ref().ok_or_else(|| {
181 anyhow::anyhow!("DataLink client requires startup parameters (interface)")
182 })?;
183
184 let interface = params.get_string("interface")?;
185 let promiscuous = params.get_optional_bool("promiscuous")?.unwrap_or(false);
186
187 info!(
188 "DataLink client {} opening interface: {} (promiscuous: {})",
189 client_id, interface, promiscuous
190 );
191
192 // Create channel for frame injection commands
193 let (inject_tx, mut inject_rx) = mpsc::unbounded_channel::<InjectionCommand>();
194 let inject_tx_cmd = inject_tx.clone();
195 let inject_tx_arc = Arc::new(Mutex::new(inject_tx));
196
197 // Capture-side bookkeeping (one LLM turn at a time per client).
198 let client_data = Arc::new(Mutex::new(ClientData {
199 state: ConnectionState::Idle,
200 dropped_while_busy: 0,
201 }));
202
203 // Stops the blocking loop. Registered below, once the capture is genuinely open.
204 let stop = StopSignal::new();
205 let stop_in_loop = stop.clone();
206 let stop_for_cmds = stop.clone();
207 let stop_for_conn = stop.clone();
208
209 // Whether the pcap handle really opened. `connect` returns Err if it did not, so a
210 // client on a host without BPF access lands in ClientStatus::Error instead of sitting
211 // in Connected having opened nothing.
212 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<Result<()>>();
213
214 // Clone for the blocking task
215 let inject_tx_arc_thread = inject_tx_arc.clone();
216 // ...and for the connected-event task below, which is spawned after the blocking
217 // closure has taken ownership of `inject_tx_arc`.
218 let inject_tx_conn = inject_tx_arc.clone();
219 let interface_clone = interface.clone();
220 let status_tx_clone = status_tx.clone();
221 let app_state_clone = app_state.clone();
222 let llm_client_clone = llm_client.clone();
223 let client_data_clone = client_data.clone();
224
225 // Spawn blocking task for pcap operations
226 tokio::task::spawn_blocking(move || {
227 let open_capture = || -> Result<pcap::Capture<pcap::Active>> {
228 let device = Self::find_device(&interface_clone)
229 .with_context(|| format!("no such capture device '{}'", interface_clone))?;
230
231 Capture::from_device(device)
232 .map(|c| c.promisc(promiscuous).snaplen(65535).timeout(100))
233 .and_then(|c| c.open())
234 .with_context(|| {
235 format!(
236 "failed to open pcap capture on '{}' (needs root, or read access \
237 to /dev/bpf* on macOS/BSD, or CAP_NET_RAW on Linux)",
238 interface_clone
239 )
240 })
241 };
242
243 let mut cap = match open_capture() {
244 Ok(cap) => {
245 let _ = ready_tx.send(Ok(()));
246 cap
247 }
248 Err(e) => {
249 error!(
250 "DataLink client {} capture startup failed: {:#}",
251 client_id, e
252 );
253 let _ = status_tx_clone.send(format!(
254 "[ERROR] DataLink client {} capture startup failed: {:#}",
255 client_id, e
256 ));
257 let _ = ready_tx.send(Err(e));
258 return;
259 }
260 };
261
262 info!("DataLink client {} capture opened successfully", client_id);
263 let _ = status_tx_clone.send(format!(
264 "[INFO] DataLink client {} ready for frame injection",
265 client_id
266 ));
267
268 let runtime = tokio::runtime::Handle::current();
269
270 // Main loop: handle injection commands and optionally capture frames.
271 // Exits when the client is removed (which aborts the task holding `stop`) or the
272 // capture errors.
273 loop {
274 if stop_in_loop.is_stopped() {
275 break;
276 }
277
278 // Check for injection commands (non-blocking)
279 match inject_rx.try_recv() {
280 Ok(cmd) => {
281 let result = match cap.sendpacket(&cmd.frame[..]) {
282 Ok(_) => {
283 trace!(
284 "DataLink client {} injected frame ({} bytes)",
285 client_id,
286 cmd.frame.len()
287 );
288 let _ = status_tx_clone.send(format!(
289 "[TRACE] DataLink client {} injected frame ({} bytes)",
290 client_id,
291 cmd.frame.len()
292 ));
293
294 // Tell the model the frame went out. `datalink_frame_injected`
295 // was declared in get_event_types() and raised nowhere, so a
296 // model that injected a frame was never told it had worked and
297 // could not follow it with anything. Spawned onto the runtime
298 // rather than awaited: this is the blocking pcap thread, and
299 // the LLM call can park for minutes on a manual routing rule.
300 //
301 // Bounded: past MAX_INJECTION_FOLLOWUPS hops the frame still
302 // goes out, the model is simply not asked again.
303 if cmd.llm_depth < MAX_INJECTION_FOLLOWUPS {
304 let ev_state = app_state_clone.clone();
305 let ev_llm = llm_client_clone.clone();
306 let ev_status = status_tx_clone.clone();
307 let ev_inject = inject_tx_arc_thread.clone();
308 let ev_stop = stop_in_loop.clone();
309 let ev_frame = cmd.frame.clone();
310 let ev_depth = cmd.llm_depth;
311 runtime.spawn(async move {
312 Self::report_injection(
313 client_id, ev_frame, ev_depth, ev_state, ev_llm,
314 ev_status, ev_inject, ev_stop,
315 )
316 .await;
317 });
318 } else {
319 warn!(
320 "DataLink client {} decision=followup_depth_capped: \
321 {}-byte frame sent, model not asked again after {} \
322 inject→report→inject hops",
323 client_id,
324 cmd.frame.len(),
325 MAX_INJECTION_FOLLOWUPS
326 );
327 let _ = status_tx_clone.send(format!(
328 "[WARN] DataLink client {} stopped an injection chain \
329 at depth {}",
330 client_id, MAX_INJECTION_FOLLOWUPS
331 ));
332 }
333
334 Ok(cmd.frame.len())
335 }
336 Err(e) => {
337 error!(
338 "DataLink client {} frame injection failed: {}",
339 client_id, e
340 );
341 let _ = status_tx_clone.send(format!(
342 "[ERROR] DataLink client {} frame injection failed: {}",
343 client_id, e
344 ));
345 Err(e.to_string())
346 }
347 };
348 if let Some(ack) = cmd.ack {
349 let _ = ack.send(result);
350 }
351 }
352 Err(mpsc::error::TryRecvError::Disconnected) => break,
353 Err(mpsc::error::TryRecvError::Empty) => {}
354 }
355
356 // If promiscuous mode, capture frames
357 if promiscuous {
358 match cap.next_packet() {
359 Ok(packet) => {
360 let frame = packet.data.to_vec();
361 trace!(
362 "DataLink client {} captured frame ({} bytes)",
363 client_id,
364 frame.len()
365 );
366
367 let state_clone = app_state_clone.clone();
368 let llm_clone = llm_client_clone.clone();
369 let status_clone = status_tx_clone.clone();
370 let client_data_task = client_data_clone.clone();
371 let inject_tx_task = inject_tx_arc_thread.clone();
372 let stop_task = stop_in_loop.clone();
373
374 runtime.spawn(async move {
375 Self::handle_captured_frame(
376 client_id,
377 frame,
378 client_data_task,
379 state_clone,
380 llm_clone,
381 status_clone,
382 inject_tx_task,
383 stop_task,
384 )
385 .await;
386 });
387 }
388 Err(pcap::Error::TimeoutExpired) => {
389 // Normal timeout, continue
390 }
391 Err(e) => {
392 error!("DataLink client {} capture error: {}", client_id, e);
393 let _ = status_tx_clone.send(format!(
394 "[ERROR] DataLink client {} capture error: {}",
395 client_id, e
396 ));
397 break;
398 }
399 }
400 } else {
401 // Nothing blocks in injection-only mode, so bound the poll rate and the
402 // latency with which a stop is noticed.
403 std::thread::sleep(std::time::Duration::from_millis(10));
404 }
405 }
406
407 info!("DataLink client {} disconnected", client_id);
408 runtime.block_on(async {
409 let dropped = client_data_clone.lock().await.dropped_while_busy;
410 if dropped > 0 {
411 warn!(
412 "DataLink client {} dropped {} captured frame(s) that arrived while a \
413 model turn was already running",
414 client_id, dropped
415 );
416 }
417 app_state_clone
418 .update_client_status(client_id, ClientStatus::Disconnected)
419 .await;
420 // The pcap handle is gone: drop the command handle so the dashboard stops
421 // offering [ send ] on a client that can no longer inject anything. This
422 // also closes the command channel, which ends `command_loop`.
423 app_state_clone.remove_client_handle(client_id).await;
424 let _ = status_tx_clone.send(format!(
425 "[CLIENT] DataLink client {} disconnected",
426 client_id
427 ));
428 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
429 });
430 });
431
432 // Wait for the blocking task to report whether the capture actually came up. Nothing
433 // below this line runs for a client that has no capture handle.
434 match ready_rx.await {
435 Ok(Ok(())) => {}
436 Ok(Err(e)) => return Err(e),
437 Err(_) => {
438 return Err(anyhow::anyhow!(
439 "DataLink capture task on '{}' exited before signalling readiness",
440 interface
441 ))
442 }
443 }
444
445 app_state
446 .update_client_status(client_id, ClientStatus::Connected)
447 .await;
448 let _ = status_tx.send(format!(
449 "[CLIENT] DataLink client {} connected to interface {}",
450 client_id, interface
451 ));
452 let _ = status_tx.send("__UPDATE_UI__".to_string());
453
454 // Ends the blocking loop when the client is removed: `remove_client` aborts every
455 // registered task, and dropping this one's future trips `stop`.
456 app_state
457 .register_client_task(client_id, stop.park_task())
458 .await;
459
460 // Command channel for injected actions (the dashboard's [ send ]). Registered before
461 // the connected-event call below, which a `*` -> manual routing rule can park for
462 // minutes — `[ send ]` must be live throughout that.
463 let protocol = Arc::new(DataLinkClientProtocol::new());
464 let command_rx =
465 crate::client::command_support::register_command_channel(&app_state, client_id).await;
466 let cmd_task = tokio::spawn(Self::command_loop(
467 command_rx,
468 protocol,
469 inject_tx_cmd,
470 client_id,
471 app_state.clone(),
472 status_tx.clone(),
473 stop_for_cmds,
474 ));
475 app_state.register_client_task(client_id, cmd_task).await;
476
477 // Ask the model what to do now that the interface is open.
478 //
479 // This client used to make no connected-event LLM call at all, so a client created
480 // with "inject an ARP request for 10.0.0.2" opened the capture and then sat there:
481 // nothing consulted the model, so nothing was ever injected. Run from a registered
482 // task rather than inline, because a dashboard-created client defaults to a
483 // `*` -> manual rule and that call can park for minutes -- `connect` must return.
484 let conn_state = app_state.clone();
485 let conn_llm = llm_client.clone();
486 let conn_status = status_tx.clone();
487 let conn_inject = inject_tx_conn;
488 let conn_interface = interface.clone();
489 let conn_task = tokio::spawn(async move {
490 let Some(instruction) = conn_state.get_instruction_for_client(client_id).await else {
491 return;
492 };
493 let event = Event::new(
494 &DATALINK_CLIENT_CONNECTED_EVENT,
495 serde_json::json!({
496 "interface": conn_interface,
497 "promiscuous": promiscuous,
498 }),
499 );
500 Self::run_llm_turn(
501 client_id,
502 &instruction,
503 event,
504 0,
505 &conn_state,
506 &conn_llm,
507 &conn_status,
508 &conn_inject,
509 &stop_for_conn,
510 )
511 .await;
512 });
513 app_state.register_client_task(client_id, conn_task).await;
514
515 // The interface name is stored in the client metadata
516 Ok(SocketAddr::from(([127, 0, 0, 1], 0)))
517 }
518
519 /// One captured frame: ask the model, unless a turn is already running for this client.
520 ///
521 /// The frames dropped here used to be pushed onto a `Vec` that nothing ever read, which
522 /// on a busy interface grew without bound while the model was parked.
523 #[allow(clippy::too_many_arguments)]
524 async fn handle_captured_frame(
525 client_id: ClientId,
526 frame: Vec<u8>,
527 client_data: Arc<Mutex<ClientData>>,
528 app_state: Arc<AppState>,
529 llm_client: OllamaClient,
530 status_tx: mpsc::UnboundedSender<String>,
531 inject_tx: Arc<Mutex<mpsc::UnboundedSender<InjectionCommand>>>,
532 stop: StopSignal,
533 ) {
534 {
535 let mut data = client_data.lock().await;
536 if data.state != ConnectionState::Idle {
537 data.dropped_while_busy += 1;
538 let dropped = data.dropped_while_busy;
539 drop(data);
540 if dropped == 1 || dropped % 100 == 0 {
541 warn!(
542 "DataLink client {} dropped {} captured frame(s): a model turn was \
543 already running. Use promiscuous mode with a quiet interface, or a \
544 script/static handler, for high-rate capture.",
545 client_id, dropped
546 );
547 }
548 return;
549 }
550 data.state = ConnectionState::Processing;
551 }
552
553 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
554 let event = Event::new(
555 &DATALINK_CLIENT_FRAME_CAPTURED_EVENT,
556 frame_event_fields(&frame),
557 );
558 Self::run_llm_turn(
559 client_id,
560 &instruction,
561 event,
562 0,
563 &app_state,
564 &llm_client,
565 &status_tx,
566 &inject_tx,
567 &stop,
568 )
569 .await;
570 }
571
572 client_data.lock().await.state = ConnectionState::Idle;
573 }
574
575 /// Report a frame that really went out, and queue whatever the model answers with.
576 #[allow(clippy::too_many_arguments)]
577 async fn report_injection(
578 client_id: ClientId,
579 frame: Vec<u8>,
580 llm_depth: u8,
581 app_state: Arc<AppState>,
582 llm_client: OllamaClient,
583 status_tx: mpsc::UnboundedSender<String>,
584 inject_tx: Arc<Mutex<mpsc::UnboundedSender<InjectionCommand>>>,
585 stop: StopSignal,
586 ) {
587 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
588 return;
589 };
590 let event = Event::new(
591 &DATALINK_CLIENT_FRAME_INJECTED_EVENT,
592 frame_event_fields(&frame),
593 );
594 Self::run_llm_turn(
595 client_id,
596 &instruction,
597 event,
598 llm_depth.saturating_add(1),
599 &app_state,
600 &llm_client,
601 &status_tx,
602 &inject_tx,
603 &stop,
604 )
605 .await;
606 }
607
608 /// One LLM turn: raise `event`, then queue any frames the model asks to inject.
609 ///
610 /// Deliberately does NOT recurse. Injecting a frame raises `datalink_frame_injected`
611 /// from the pcap loop, which comes back here, so the chain continues through the queue
612 /// rather than through the stack. `depth` is what bounds it: the frames queued here carry
613 /// it, and the pcap loop stops raising the follow-up event past
614 /// [`MAX_INJECTION_FOLLOWUPS`].
615 #[allow(clippy::too_many_arguments)]
616 async fn run_llm_turn(
617 client_id: ClientId,
618 instruction: &str,
619 event: Event,
620 depth: u8,
621 app_state: &Arc<AppState>,
622 llm_client: &OllamaClient,
623 status_tx: &mpsc::UnboundedSender<String>,
624 inject_tx: &Arc<Mutex<mpsc::UnboundedSender<InjectionCommand>>>,
625 stop: &StopSignal,
626 ) {
627 use crate::llm::actions::client_trait::{Client, ClientActionResult};
628
629 let protocol = DataLinkClientProtocol::new();
630 let memory = app_state
631 .get_memory_for_client(client_id)
632 .await
633 .unwrap_or_default();
634 let event_id = event.event_type.id.clone();
635
636 match call_llm_for_client(
637 llm_client,
638 app_state,
639 client_id.to_string(),
640 instruction,
641 &memory,
642 Some(&event),
643 &protocol,
644 status_tx,
645 )
646 .await
647 {
648 Ok(ClientLlmResult {
649 actions,
650 memory_updates,
651 }) => {
652 if let Some(mem) = memory_updates {
653 app_state.set_memory_for_client(client_id, mem).await;
654 }
655 // Nothing goes on the wire in three of the four outcomes, and they are
656 // indistinguishable there, so the log has to tell them apart. Grep `decision=`.
657 let mut injected = 0usize;
658 let mut rejected = 0usize;
659 let action_count = actions.len();
660 for action in actions {
661 match protocol.execute_action(action) {
662 Ok(ClientActionResult::SendData(frame)) => {
663 injected += 1;
664 let _ = inject_tx.lock().await.send(InjectionCommand {
665 frame,
666 ack: None,
667 llm_depth: depth,
668 });
669 }
670 Ok(ClientActionResult::Disconnect) => {
671 info!("DataLink client {} decision=model_disconnect", client_id);
672 Self::shut_down(client_id, app_state, stop, status_tx).await;
673 }
674 Ok(_) => {}
675 Err(e) => {
676 rejected += 1;
677 error!(
678 "DataLink client {} rejected action on {}: {}",
679 client_id, event_id, e
680 );
681 }
682 }
683 }
684 let decision = if injected > 0 {
685 "model_inject"
686 } else if action_count == 0 {
687 "model_silent"
688 } else if rejected == action_count {
689 "model_reject"
690 } else {
691 "model_no_frame"
692 };
693 info!(
694 "DataLink client {} {} decision={} depth={} ({} action(s), {} injected)",
695 client_id, event_id, decision, depth, action_count, injected
696 );
697 }
698 Err(e) => {
699 // Nothing is written to the interface on failure. DataLink is one of the
700 // deliberately silent protocols: a fabricated frame on a real network is
701 // worse than no frame at all. The category is kept separate from the error
702 // text so a saturated backend is distinguishable from a broken one.
703 let category = if crate::utils::WireFailure::classify(&e).is_overloaded() {
704 "overloaded"
705 } else {
706 "unavailable"
707 };
708 error!(
709 "DataLink client {} {} decision=fail_closed_llm_error category={}, nothing \
710 injected: {}",
711 client_id, event_id, category, e
712 );
713 let _ = status_tx.send(format!(
714 "[ERROR] DataLink client {} decision=fail_closed_llm_error category={}: {}",
715 client_id, category, e
716 ));
717 }
718 }
719 }
720
721 /// Tear the client down: stop the pcap loop, drop the command handle, mark it
722 /// disconnected. The blocking loop notices `stop` within one poll and does the rest.
723 async fn shut_down(
724 client_id: ClientId,
725 app_state: &Arc<AppState>,
726 stop: &StopSignal,
727 status_tx: &mpsc::UnboundedSender<String>,
728 ) {
729 stop.stop();
730 app_state.remove_client_handle(client_id).await;
731 app_state
732 .update_client_status(client_id, ClientStatus::Disconnected)
733 .await;
734 let _ = status_tx.send("__UPDATE_UI__".to_string());
735 }
736
737 /// Drain injected commands until the channel closes (client removed, or the pcap loop
738 /// exited and dropped the handle) or an injected `disconnect` ends the session.
739 ///
740 /// The generic `command_support::handle_stream_client_command` cannot serve this client:
741 /// there is no `AsyncWrite` half at all. `inject_frame` yields
742 /// `ClientActionResult::SendData`, which is handed to the same pcap injection queue the
743 /// LLM path uses; the pcap loop acknowledges the `sendpacket` call so the reply can be
744 /// truthful.
745 #[allow(clippy::too_many_arguments)]
746 async fn command_loop(
747 mut command_rx: mpsc::Receiver<ClientCommand>,
748 protocol: Arc<DataLinkClientProtocol>,
749 inject_tx: mpsc::UnboundedSender<InjectionCommand>,
750 client_id: ClientId,
751 app_state: Arc<AppState>,
752 status_tx: mpsc::UnboundedSender<String>,
753 stop: StopSignal,
754 ) {
755 use crate::llm::actions::protocol_trait::Protocol;
756
757 while let Some(command) = command_rx.recv().await {
758 let action = command.action.clone();
759 let outcome = Self::execute_injected_action(&protocol, &inject_tx, &action).await;
760
761 let outcome_json = match &outcome {
762 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
763 Err(e) => serde_json::json!({"error": e.to_string()}),
764 };
765 app_state
766 .record_access_log(
767 AccessLogOwner::Client(client_id.as_u32()),
768 protocol.protocol_name(),
769 None,
770 "injected_action",
771 action,
772 vec![outcome_json],
773 )
774 .await;
775
776 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
777 if let Err(e) = &outcome {
778 error!(
779 "DataLink client {} injected action failed: {}",
780 client_id, e
781 );
782 let _ = status_tx.send(format!(
783 "[WARN] Client {} injected action failed: {}",
784 client_id, e
785 ));
786 }
787 let _ = status_tx.send("__UPDATE_UI__".to_string());
788 crate::client::command_support::reply(command, outcome);
789
790 if disconnect {
791 Self::shut_down(client_id, &app_state, &stop, &status_tx).await;
792 break;
793 }
794 }
795 }
796
797 /// Execute one injected action and report exactly what happened to it.
798 ///
799 /// - `Sent { bytes_sent }` only after the pcap loop has acknowledged a successful
800 /// `sendpacket` for that many bytes.
801 /// - `Executed { detail }` when the frame could not be handed over — the pcap loop has
802 /// exited, which for a live client means it is on its way down.
803 /// - `Rejected { error }` for an action the protocol refuses (unknown type, bad hex,
804 /// a frame too short or too long to be an Ethernet frame).
805 /// - `Err` when pcap accepted the frame and failed to transmit it.
806 async fn execute_injected_action(
807 protocol: &Arc<DataLinkClientProtocol>,
808 inject_tx: &mpsc::UnboundedSender<InjectionCommand>,
809 action: &serde_json::Value,
810 ) -> Result<ClientSendOutcome> {
811 use crate::llm::actions::client_trait::{Client, ClientActionResult};
812
813 let result = match protocol.as_ref().execute_action(action.clone()) {
814 Ok(result) => result,
815 Err(e) => {
816 return Ok(ClientSendOutcome::Rejected {
817 error: e.to_string(),
818 })
819 }
820 };
821
822 let frame = match result {
823 ClientActionResult::SendData(frame) => frame,
824 ClientActionResult::Disconnect => return Ok(ClientSendOutcome::Disconnected),
825 ClientActionResult::WaitForMore => {
826 return Ok(ClientSendOutcome::Executed {
827 detail: "wait_for_more".to_string(),
828 })
829 }
830 other => {
831 return Ok(ClientSendOutcome::Executed {
832 detail: format!("{other:?} injects no frame"),
833 })
834 }
835 };
836
837 let frame_len = frame.len();
838 let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
839 if inject_tx
840 .send(InjectionCommand {
841 frame,
842 ack: Some(ack_tx),
843 // The operator asking for a frame starts a fresh chain, however deep the
844 // model's own chain went.
845 llm_depth: 0,
846 })
847 .is_err()
848 {
849 // The pcap loop has exited and dropped the receiver. Since `connect` now awaits
850 // the capture handle, this is a client on its way down rather than one that never
851 // opened anything.
852 return Ok(ClientSendOutcome::Executed {
853 detail: format!(
854 "{frame_len}-byte frame built but not injected: the pcap capture is no \
855 longer open"
856 ),
857 });
858 }
859
860 match tokio::time::timeout(INJECTION_ACK_TIMEOUT, ack_rx).await {
861 Ok(Ok(Ok(bytes_sent))) => Ok(ClientSendOutcome::Sent { bytes_sent }),
862 Ok(Ok(Err(e))) => Err(anyhow::anyhow!("pcap sendpacket failed: {e}")),
863 Ok(Err(_)) => Ok(ClientSendOutcome::Executed {
864 detail: format!(
865 "{frame_len}-byte frame not injected: it was handed to the pcap loop, \
866 which exited without reporting the result"
867 ),
868 }),
869 Err(_) => Ok(ClientSendOutcome::Executed {
870 detail: format!(
871 "{frame_len}-byte frame not confirmed injected: the pcap loop did not \
872 acknowledge it within {}s",
873 INJECTION_ACK_TIMEOUT.as_secs()
874 ),
875 }),
876 }
877 }
878
879 /// Find a network device by name
880 fn find_device(name: &str) -> Result<Device> {
881 let devices = Device::list().context("Failed to list network devices")?;
882 devices
883 .into_iter()
884 .find(|d| d.name == name)
885 .ok_or_else(|| anyhow::anyhow!("Network device '{}' not found", name))
886 }
887}