pub mod actions;
pub use actions::DataLinkClientProtocol;
use anyhow::{Context, Result};
use pcap::{Capture, Device};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use tracing::{error, info, trace, warn};
use crate::client::datalink::actions::{
DATALINK_CLIENT_CONNECTED_EVENT, DATALINK_CLIENT_FRAME_CAPTURED_EVENT,
DATALINK_CLIENT_FRAME_INJECTED_EVENT,
};
use crate::client::llm_budget::call_llm_for_client;
use crate::llm::ollama_client::OllamaClient;
use crate::llm::ClientLlmResult;
use crate::protocol::{Event, StartupParams};
use crate::state::app_state::AppState;
use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
use crate::state::{AccessLogOwner, ClientId, ClientStatus};
use crate::utils::StopSignal;
#[derive(Debug, Clone, Copy, PartialEq)]
enum ConnectionState {
Idle,
Processing,
}
struct ClientData {
state: ConnectionState,
dropped_while_busy: u64,
}
struct InjectionCommand {
frame: Vec<u8>,
ack: Option<tokio::sync::oneshot::Sender<std::result::Result<usize, String>>>,
llm_depth: u8,
}
const INJECTION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
const MAX_INJECTION_FOLLOWUPS: u8 = 4;
pub const MAX_HEX_BYTES_TO_MODEL: usize = 2048;
pub fn frame_event_fields(frame: &[u8]) -> serde_json::Value {
use crate::client::datalink::actions::{format_mac, MIN_ETHERNET_FRAME_BYTES};
let shown = frame.len().min(MAX_HEX_BYTES_TO_MODEL);
let visible = &frame[..shown];
let header = (visible.len() >= MIN_ETHERNET_FRAME_BYTES).then(|| {
(
format_mac(&visible[0..6]),
format_mac(&visible[6..12]),
format!("0x{:04x}", u16::from_be_bytes([visible[12], visible[13]])),
)
});
let payload = header
.as_ref()
.map(|_| &visible[MIN_ETHERNET_FRAME_BYTES..])
.unwrap_or(&[]);
let (payload_text, payload_encoding) = if payload
.iter()
.all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
{
(String::from_utf8_lossy(payload).to_string(), "utf8")
} else {
(hex::encode(payload), "hex")
};
serde_json::json!({
"frame_hex": hex::encode(visible),
"frame_length": frame.len(),
"captured_length": shown,
"truncated": shown < frame.len(),
"dst_mac": header.as_ref().map(|h| h.0.clone()),
"src_mac": header.as_ref().map(|h| h.1.clone()),
"ethertype": header.as_ref().map(|h| h.2.clone()),
"payload": payload_text,
"payload_encoding": payload_encoding,
})
}
pub struct DataLinkClient;
impl DataLinkClient {
pub async fn connect_with_llm_actions(
_remote_addr: String, llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
client_id: ClientId,
startup_params: Option<StartupParams>,
) -> Result<SocketAddr> {
let params = startup_params.as_ref().ok_or_else(|| {
anyhow::anyhow!("DataLink client requires startup parameters (interface)")
})?;
let interface = params.get_string("interface")?;
let promiscuous = params.get_optional_bool("promiscuous")?.unwrap_or(false);
info!(
"DataLink client {} opening interface: {} (promiscuous: {})",
client_id, interface, promiscuous
);
let (inject_tx, mut inject_rx) = mpsc::unbounded_channel::<InjectionCommand>();
let inject_tx_cmd = inject_tx.clone();
let inject_tx_arc = Arc::new(Mutex::new(inject_tx));
let client_data = Arc::new(Mutex::new(ClientData {
state: ConnectionState::Idle,
dropped_while_busy: 0,
}));
let stop = StopSignal::new();
let stop_in_loop = stop.clone();
let stop_for_cmds = stop.clone();
let stop_for_conn = stop.clone();
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<Result<()>>();
let inject_tx_arc_thread = inject_tx_arc.clone();
let inject_tx_conn = inject_tx_arc.clone();
let interface_clone = interface.clone();
let status_tx_clone = status_tx.clone();
let app_state_clone = app_state.clone();
let llm_client_clone = llm_client.clone();
let client_data_clone = client_data.clone();
tokio::task::spawn_blocking(move || {
let open_capture = || -> Result<pcap::Capture<pcap::Active>> {
let device = Self::find_device(&interface_clone)
.with_context(|| format!("no such capture device '{}'", interface_clone))?;
Capture::from_device(device)
.map(|c| c.promisc(promiscuous).snaplen(65535).timeout(100))
.and_then(|c| c.open())
.with_context(|| {
format!(
"failed to open pcap capture on '{}' (needs root, or read access \
to /dev/bpf* on macOS/BSD, or CAP_NET_RAW on Linux)",
interface_clone
)
})
};
let mut cap = match open_capture() {
Ok(cap) => {
let _ = ready_tx.send(Ok(()));
cap
}
Err(e) => {
error!(
"DataLink client {} capture startup failed: {:#}",
client_id, e
);
let _ = status_tx_clone.send(format!(
"[ERROR] DataLink client {} capture startup failed: {:#}",
client_id, e
));
let _ = ready_tx.send(Err(e));
return;
}
};
info!("DataLink client {} capture opened successfully", client_id);
let _ = status_tx_clone.send(format!(
"[INFO] DataLink client {} ready for frame injection",
client_id
));
let runtime = tokio::runtime::Handle::current();
loop {
if stop_in_loop.is_stopped() {
break;
}
match inject_rx.try_recv() {
Ok(cmd) => {
let result = match cap.sendpacket(&cmd.frame[..]) {
Ok(_) => {
trace!(
"DataLink client {} injected frame ({} bytes)",
client_id,
cmd.frame.len()
);
let _ = status_tx_clone.send(format!(
"[TRACE] DataLink client {} injected frame ({} bytes)",
client_id,
cmd.frame.len()
));
if cmd.llm_depth < MAX_INJECTION_FOLLOWUPS {
let ev_state = app_state_clone.clone();
let ev_llm = llm_client_clone.clone();
let ev_status = status_tx_clone.clone();
let ev_inject = inject_tx_arc_thread.clone();
let ev_stop = stop_in_loop.clone();
let ev_frame = cmd.frame.clone();
let ev_depth = cmd.llm_depth;
runtime.spawn(async move {
Self::report_injection(
client_id, ev_frame, ev_depth, ev_state, ev_llm,
ev_status, ev_inject, ev_stop,
)
.await;
});
} else {
warn!(
"DataLink client {} decision=followup_depth_capped: \
{}-byte frame sent, model not asked again after {} \
inject→report→inject hops",
client_id,
cmd.frame.len(),
MAX_INJECTION_FOLLOWUPS
);
let _ = status_tx_clone.send(format!(
"[WARN] DataLink client {} stopped an injection chain \
at depth {}",
client_id, MAX_INJECTION_FOLLOWUPS
));
}
Ok(cmd.frame.len())
}
Err(e) => {
error!(
"DataLink client {} frame injection failed: {}",
client_id, e
);
let _ = status_tx_clone.send(format!(
"[ERROR] DataLink client {} frame injection failed: {}",
client_id, e
));
Err(e.to_string())
}
};
if let Some(ack) = cmd.ack {
let _ = ack.send(result);
}
}
Err(mpsc::error::TryRecvError::Disconnected) => break,
Err(mpsc::error::TryRecvError::Empty) => {}
}
if promiscuous {
match cap.next_packet() {
Ok(packet) => {
let frame = packet.data.to_vec();
trace!(
"DataLink client {} captured frame ({} bytes)",
client_id,
frame.len()
);
let state_clone = app_state_clone.clone();
let llm_clone = llm_client_clone.clone();
let status_clone = status_tx_clone.clone();
let client_data_task = client_data_clone.clone();
let inject_tx_task = inject_tx_arc_thread.clone();
let stop_task = stop_in_loop.clone();
runtime.spawn(async move {
Self::handle_captured_frame(
client_id,
frame,
client_data_task,
state_clone,
llm_clone,
status_clone,
inject_tx_task,
stop_task,
)
.await;
});
}
Err(pcap::Error::TimeoutExpired) => {
}
Err(e) => {
error!("DataLink client {} capture error: {}", client_id, e);
let _ = status_tx_clone.send(format!(
"[ERROR] DataLink client {} capture error: {}",
client_id, e
));
break;
}
}
} else {
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
info!("DataLink client {} disconnected", client_id);
runtime.block_on(async {
let dropped = client_data_clone.lock().await.dropped_while_busy;
if dropped > 0 {
warn!(
"DataLink client {} dropped {} captured frame(s) that arrived while a \
model turn was already running",
client_id, dropped
);
}
app_state_clone
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
app_state_clone.remove_client_handle(client_id).await;
let _ = status_tx_clone.send(format!(
"[CLIENT] DataLink client {} disconnected",
client_id
));
let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
});
});
match ready_rx.await {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(e),
Err(_) => {
return Err(anyhow::anyhow!(
"DataLink capture task on '{}' exited before signalling readiness",
interface
))
}
}
app_state
.update_client_status(client_id, ClientStatus::Connected)
.await;
let _ = status_tx.send(format!(
"[CLIENT] DataLink client {} connected to interface {}",
client_id, interface
));
let _ = status_tx.send("__UPDATE_UI__".to_string());
app_state
.register_client_task(client_id, stop.park_task())
.await;
let protocol = Arc::new(DataLinkClientProtocol::new());
let command_rx =
crate::client::command_support::register_command_channel(&app_state, client_id).await;
let cmd_task = tokio::spawn(Self::command_loop(
command_rx,
protocol,
inject_tx_cmd,
client_id,
app_state.clone(),
status_tx.clone(),
stop_for_cmds,
));
app_state.register_client_task(client_id, cmd_task).await;
let conn_state = app_state.clone();
let conn_llm = llm_client.clone();
let conn_status = status_tx.clone();
let conn_inject = inject_tx_conn;
let conn_interface = interface.clone();
let conn_task = tokio::spawn(async move {
let Some(instruction) = conn_state.get_instruction_for_client(client_id).await else {
return;
};
let event = Event::new(
&DATALINK_CLIENT_CONNECTED_EVENT,
serde_json::json!({
"interface": conn_interface,
"promiscuous": promiscuous,
}),
);
Self::run_llm_turn(
client_id,
&instruction,
event,
0,
&conn_state,
&conn_llm,
&conn_status,
&conn_inject,
&stop_for_conn,
)
.await;
});
app_state.register_client_task(client_id, conn_task).await;
Ok(SocketAddr::from(([127, 0, 0, 1], 0)))
}
#[allow(clippy::too_many_arguments)]
async fn handle_captured_frame(
client_id: ClientId,
frame: Vec<u8>,
client_data: Arc<Mutex<ClientData>>,
app_state: Arc<AppState>,
llm_client: OllamaClient,
status_tx: mpsc::UnboundedSender<String>,
inject_tx: Arc<Mutex<mpsc::UnboundedSender<InjectionCommand>>>,
stop: StopSignal,
) {
{
let mut data = client_data.lock().await;
if data.state != ConnectionState::Idle {
data.dropped_while_busy += 1;
let dropped = data.dropped_while_busy;
drop(data);
if dropped == 1 || dropped % 100 == 0 {
warn!(
"DataLink client {} dropped {} captured frame(s): a model turn was \
already running. Use promiscuous mode with a quiet interface, or a \
script/static handler, for high-rate capture.",
client_id, dropped
);
}
return;
}
data.state = ConnectionState::Processing;
}
if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
let event = Event::new(
&DATALINK_CLIENT_FRAME_CAPTURED_EVENT,
frame_event_fields(&frame),
);
Self::run_llm_turn(
client_id,
&instruction,
event,
0,
&app_state,
&llm_client,
&status_tx,
&inject_tx,
&stop,
)
.await;
}
client_data.lock().await.state = ConnectionState::Idle;
}
#[allow(clippy::too_many_arguments)]
async fn report_injection(
client_id: ClientId,
frame: Vec<u8>,
llm_depth: u8,
app_state: Arc<AppState>,
llm_client: OllamaClient,
status_tx: mpsc::UnboundedSender<String>,
inject_tx: Arc<Mutex<mpsc::UnboundedSender<InjectionCommand>>>,
stop: StopSignal,
) {
let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
return;
};
let event = Event::new(
&DATALINK_CLIENT_FRAME_INJECTED_EVENT,
frame_event_fields(&frame),
);
Self::run_llm_turn(
client_id,
&instruction,
event,
llm_depth.saturating_add(1),
&app_state,
&llm_client,
&status_tx,
&inject_tx,
&stop,
)
.await;
}
#[allow(clippy::too_many_arguments)]
async fn run_llm_turn(
client_id: ClientId,
instruction: &str,
event: Event,
depth: u8,
app_state: &Arc<AppState>,
llm_client: &OllamaClient,
status_tx: &mpsc::UnboundedSender<String>,
inject_tx: &Arc<Mutex<mpsc::UnboundedSender<InjectionCommand>>>,
stop: &StopSignal,
) {
use crate::llm::actions::client_trait::{Client, ClientActionResult};
let protocol = DataLinkClientProtocol::new();
let memory = app_state
.get_memory_for_client(client_id)
.await
.unwrap_or_default();
let event_id = event.event_type.id.clone();
match call_llm_for_client(
llm_client,
app_state,
client_id.to_string(),
instruction,
&memory,
Some(&event),
&protocol,
status_tx,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
app_state.set_memory_for_client(client_id, mem).await;
}
let mut injected = 0usize;
let mut rejected = 0usize;
let action_count = actions.len();
for action in actions {
match protocol.execute_action(action) {
Ok(ClientActionResult::SendData(frame)) => {
injected += 1;
let _ = inject_tx.lock().await.send(InjectionCommand {
frame,
ack: None,
llm_depth: depth,
});
}
Ok(ClientActionResult::Disconnect) => {
info!("DataLink client {} decision=model_disconnect", client_id);
Self::shut_down(client_id, app_state, stop, status_tx).await;
}
Ok(_) => {}
Err(e) => {
rejected += 1;
error!(
"DataLink client {} rejected action on {}: {}",
client_id, event_id, e
);
}
}
}
let decision = if injected > 0 {
"model_inject"
} else if action_count == 0 {
"model_silent"
} else if rejected == action_count {
"model_reject"
} else {
"model_no_frame"
};
info!(
"DataLink client {} {} decision={} depth={} ({} action(s), {} injected)",
client_id, event_id, decision, depth, action_count, injected
);
}
Err(e) => {
let category = if crate::utils::WireFailure::classify(&e).is_overloaded() {
"overloaded"
} else {
"unavailable"
};
error!(
"DataLink client {} {} decision=fail_closed_llm_error category={}, nothing \
injected: {}",
client_id, event_id, category, e
);
let _ = status_tx.send(format!(
"[ERROR] DataLink client {} decision=fail_closed_llm_error category={}: {}",
client_id, category, e
));
}
}
}
async fn shut_down(
client_id: ClientId,
app_state: &Arc<AppState>,
stop: &StopSignal,
status_tx: &mpsc::UnboundedSender<String>,
) {
stop.stop();
app_state.remove_client_handle(client_id).await;
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
}
#[allow(clippy::too_many_arguments)]
async fn command_loop(
mut command_rx: mpsc::Receiver<ClientCommand>,
protocol: Arc<DataLinkClientProtocol>,
inject_tx: mpsc::UnboundedSender<InjectionCommand>,
client_id: ClientId,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
stop: StopSignal,
) {
use crate::llm::actions::protocol_trait::Protocol;
while let Some(command) = command_rx.recv().await {
let action = command.action.clone();
let outcome = Self::execute_injected_action(&protocol, &inject_tx, &action).await;
let outcome_json = match &outcome {
Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
Err(e) => serde_json::json!({"error": e.to_string()}),
};
app_state
.record_access_log(
AccessLogOwner::Client(client_id.as_u32()),
protocol.protocol_name(),
None,
"injected_action",
action,
vec![outcome_json],
)
.await;
let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
if let Err(e) = &outcome {
error!(
"DataLink client {} injected action failed: {}",
client_id, e
);
let _ = status_tx.send(format!(
"[WARN] Client {} injected action failed: {}",
client_id, e
));
}
let _ = status_tx.send("__UPDATE_UI__".to_string());
crate::client::command_support::reply(command, outcome);
if disconnect {
Self::shut_down(client_id, &app_state, &stop, &status_tx).await;
break;
}
}
}
async fn execute_injected_action(
protocol: &Arc<DataLinkClientProtocol>,
inject_tx: &mpsc::UnboundedSender<InjectionCommand>,
action: &serde_json::Value,
) -> Result<ClientSendOutcome> {
use crate::llm::actions::client_trait::{Client, ClientActionResult};
let result = match protocol.as_ref().execute_action(action.clone()) {
Ok(result) => result,
Err(e) => {
return Ok(ClientSendOutcome::Rejected {
error: e.to_string(),
})
}
};
let frame = match result {
ClientActionResult::SendData(frame) => frame,
ClientActionResult::Disconnect => return Ok(ClientSendOutcome::Disconnected),
ClientActionResult::WaitForMore => {
return Ok(ClientSendOutcome::Executed {
detail: "wait_for_more".to_string(),
})
}
other => {
return Ok(ClientSendOutcome::Executed {
detail: format!("{other:?} injects no frame"),
})
}
};
let frame_len = frame.len();
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
if inject_tx
.send(InjectionCommand {
frame,
ack: Some(ack_tx),
llm_depth: 0,
})
.is_err()
{
return Ok(ClientSendOutcome::Executed {
detail: format!(
"{frame_len}-byte frame built but not injected: the pcap capture is no \
longer open"
),
});
}
match tokio::time::timeout(INJECTION_ACK_TIMEOUT, ack_rx).await {
Ok(Ok(Ok(bytes_sent))) => Ok(ClientSendOutcome::Sent { bytes_sent }),
Ok(Ok(Err(e))) => Err(anyhow::anyhow!("pcap sendpacket failed: {e}")),
Ok(Err(_)) => Ok(ClientSendOutcome::Executed {
detail: format!(
"{frame_len}-byte frame not injected: it was handed to the pcap loop, \
which exited without reporting the result"
),
}),
Err(_) => Ok(ClientSendOutcome::Executed {
detail: format!(
"{frame_len}-byte frame not confirmed injected: the pcap loop did not \
acknowledge it within {}s",
INJECTION_ACK_TIMEOUT.as_secs()
),
}),
}
}
fn find_device(name: &str) -> Result<Device> {
let devices = Device::list().context("Failed to list network devices")?;
devices
.into_iter()
.find(|d| d.name == name)
.ok_or_else(|| anyhow::anyhow!("Network device '{}' not found", name))
}
}