Skip to main content

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}