pub mod actions;
pub use actions::WhoisClientProtocol;
use crate::llm::actions::client_trait::{Client, ClientActionResult};
use anyhow::{Context, Result};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex};
use tracing::{debug, error, info, trace, warn};
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
const RESPONSE_READ_TIMEOUT: Duration = Duration::from_secs(60);
fn sanitize_query(s: &str) -> String {
crate::utils::sanitize::strip_controls(s)
}
struct Reply {
text: String,
truncated: bool,
error: Option<std::io::Error>,
}
async fn read_reply<R>(reader: &mut R, client_id: ClientId) -> Reply
where
R: tokio::io::AsyncRead + Unpin,
{
let mut response: Vec<u8> = Vec::new();
let mut buf = vec![0u8; 4096];
let mut truncated = false;
let mut error = None;
loop {
let read = match tokio::time::timeout(RESPONSE_READ_TIMEOUT, reader.read(&mut buf)).await {
Err(_) => {
warn!(
"WHOIS client {} saw no further data for {}s; treating the reply as \
complete",
client_id,
RESPONSE_READ_TIMEOUT.as_secs()
);
break;
}
Ok(read) => read,
};
match read {
Ok(0) => break,
Ok(n) => {
let room = MAX_RESPONSE_BYTES.saturating_sub(response.len());
if n > room {
response.extend_from_slice(&buf[..room]);
truncated = true;
warn!(
"WHOIS client {} reply exceeded {} bytes; keeping the head and \
stopping. A WHOIS reply has no length field, so how much arrives is \
the remote server's choice, not ours.",
client_id, MAX_RESPONSE_BYTES
);
break;
}
response.extend_from_slice(&buf[..n]);
}
Err(e) => {
error = Some(e);
break;
}
}
}
Reply {
text: String::from_utf8_lossy(&response).into_owned(),
truncated,
error,
}
}
use crate::client::llm_budget::call_llm_for_client;
use crate::client::whois::actions::{
WHOIS_CLIENT_CONNECTED_EVENT, WHOIS_CLIENT_RESPONSE_RECEIVED_EVENT,
};
use crate::llm::ollama_client::OllamaClient;
use crate::llm::ClientLlmResult;
use crate::protocol::Event;
use crate::state::app_state::AppState;
use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
use crate::state::{AccessLogOwner, ClientId, ClientStatus};
type SentQuery = Arc<std::sync::Mutex<Option<String>>>;
enum Applied {
Sent(usize),
Disconnect,
}
pub struct WhoisClient;
impl WhoisClient {
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,
) -> Result<SocketAddr> {
let stream = TcpStream::connect(&remote_addr).await.context(format!(
"Failed to connect to WHOIS server at {}",
remote_addr
))?;
let local_addr = stream.local_addr()?;
let remote_sock_addr = stream.peer_addr()?;
info!(
"WHOIS client {} connected to {} (local: {})",
client_id, remote_sock_addr, local_addr
);
app_state
.update_client_status(client_id, ClientStatus::Connected)
.await;
let _ = status_tx.send(format!("[CLIENT] WHOIS client {} connected", client_id));
let _ = status_tx.send("__UPDATE_UI__".to_string());
let (read_half, write_half) = tokio::io::split(stream);
let write_half_arc = Arc::new(Mutex::new(write_half));
let protocol = Arc::new(WhoisClientProtocol::new());
let sent_query: SentQuery = Arc::new(std::sync::Mutex::new(None));
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.clone(),
write_half_arc.clone(),
sent_query.clone(),
client_id,
app_state.clone(),
status_tx.clone(),
));
app_state.register_client_task(client_id, cmd_task).await;
let task_registrar = app_state.clone();
let task_handle = tokio::spawn(async move {
Self::session(
read_half,
write_half_arc,
protocol,
sent_query,
remote_addr,
llm_client,
app_state.clone(),
status_tx.clone(),
client_id,
)
.await;
app_state.remove_client_handle(client_id).await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
});
task_registrar
.register_client_task(client_id, task_handle)
.await;
Ok(local_addr)
}
#[allow(clippy::too_many_arguments)]
async fn session<R, W>(
mut read_half: R,
write_half: Arc<Mutex<W>>,
protocol: Arc<WhoisClientProtocol>,
sent_query: SentQuery,
remote_addr: String,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
client_id: ClientId,
) where
R: tokio::io::AsyncRead + Unpin,
W: tokio::io::AsyncWrite + Unpin,
{
let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
return;
};
let event = Event::new(
&WHOIS_CLIENT_CONNECTED_EVENT,
serde_json::json!({
"remote_addr": remote_addr,
}),
);
let memory = app_state
.get_memory_for_client(client_id)
.await
.unwrap_or_default();
match call_llm_for_client(
&llm_client,
&app_state,
client_id.to_string(),
&instruction,
&memory,
Some(&event),
protocol.as_ref(),
&status_tx,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
app_state.set_memory_for_client(client_id, mem).await;
}
for action in actions {
let result = match protocol.execute_action(action) {
Ok(result) => result,
Err(e) => {
error!("WHOIS client {} rejected action: {}", client_id, e);
continue;
}
};
match Self::apply_action(result, &write_half, &sent_query, client_id).await {
Ok(Applied::Sent(_)) => {}
Ok(Applied::Disconnect) => {
info!("WHOIS client {} disconnecting before query", client_id);
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
return;
}
Err(e) => {
error!("WHOIS client {} failed to send query: {}", client_id, e);
app_state
.update_client_status(client_id, ClientStatus::Error(e.to_string()))
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
return;
}
}
}
}
Err(e) => {
error!("LLM error for WHOIS client {}: {}", client_id, e);
let _ = status_tx.send(format!(
"[WARN] WHOIS client {} LLM error: {} (still connected; a query can be \
injected)",
client_id, e
));
}
}
if sent_query.lock().map(|q| q.is_none()).unwrap_or(true) {
info!(
"WHOIS client {} has no query yet; waiting for an injected one or the server closing",
client_id
);
}
let reply = read_reply(&mut read_half, client_id).await;
if let Some(e) = reply.error {
error!("WHOIS client {} read error: {}", client_id, e);
app_state
.update_client_status(client_id, ClientStatus::Error(e.to_string()))
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
return;
}
let response = reply.text;
let truncated = reply.truncated;
debug!(
"WHOIS client {} received {} bytes",
client_id,
response.len()
);
trace!("WHOIS response:\n{}", response);
let query = sent_query.lock().ok().and_then(|q| q.clone());
match query {
Some(query) => {
let event = Event::new(
&WHOIS_CLIENT_RESPONSE_RECEIVED_EVENT,
serde_json::json!({
"response": response,
"query": query,
"truncated": truncated,
}),
);
let memory = app_state
.get_memory_for_client(client_id)
.await
.unwrap_or_default();
match call_llm_for_client(
&llm_client,
&app_state,
client_id.to_string(),
&instruction,
&memory,
Some(&event),
protocol.as_ref(),
&status_tx,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
app_state.set_memory_for_client(client_id, mem).await;
}
use crate::llm::actions::client_trait::{Client, ClientActionResult};
for action in actions {
let Ok(ClientActionResult::Custom { name, data }) =
protocol.execute_action(action.clone())
else {
continue;
};
if name != "whois_query" {
continue;
}
let Some(q) = data.get("query").and_then(|v| v.as_str()) else {
continue;
};
match Self::run_query_once(&remote_addr, q, client_id).await {
Ok(resp) => info!(
"WHOIS client {} follow-up query {:?} returned {} bytes",
client_id,
q,
resp.len()
),
Err(e) => error!(
"WHOIS client {} follow-up query {:?} failed: {}",
client_id, q, e
),
}
}
}
Err(e) => {
error!("LLM error for WHOIS client {}: {}", client_id, e);
}
}
info!("WHOIS client {} query complete", client_id);
}
None => {
info!(
"WHOIS client {} closed by the server before any query was sent",
client_id
);
}
}
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
let _ = status_tx.send(format!("[CLIENT] WHOIS client {} disconnected", client_id));
let _ = status_tx.send("__UPDATE_UI__".to_string());
}
async fn run_query_once(remote_addr: &str, query: &str, client_id: ClientId) -> Result<String> {
use tokio::io::AsyncWriteExt;
let mut stream = TcpStream::connect(remote_addr)
.await
.with_context(|| format!("WHOIS follow-up could not reach {remote_addr}"))?;
let query = sanitize_query(query);
stream.write_all(format!("{query}\r\n").as_bytes()).await?;
stream.flush().await?;
let reply = read_reply(&mut stream, client_id).await;
if let Some(e) = reply.error {
return Err(e).with_context(|| format!("WHOIS follow-up read from {remote_addr}"));
}
Ok(reply.text)
}
async fn apply_action<W>(
result: ClientActionResult,
write_half: &Arc<Mutex<W>>,
sent_query: &SentQuery,
client_id: ClientId,
) -> Result<Applied>
where
W: tokio::io::AsyncWrite + Unpin,
{
match result {
ClientActionResult::Custom { name, data } if name == "whois_query" => {
let query = sanitize_query(
data.get("query")
.and_then(|v| v.as_str())
.context("Missing query in action data")?,
);
debug!("WHOIS client {} querying: {}", client_id, query);
let query_bytes = format!("{}\r\n", query);
{
let mut writer = write_half.lock().await;
writer.write_all(query_bytes.as_bytes()).await?;
writer.flush().await?;
}
trace!("WHOIS client {} sent query: {}", client_id, query);
if let Ok(mut slot) = sent_query.lock() {
slot.get_or_insert(query);
}
Ok(Applied::Sent(query_bytes.len()))
}
ClientActionResult::Disconnect => {
debug!("WHOIS client {} disconnecting", client_id);
let _ = write_half.lock().await.shutdown().await;
Ok(Applied::Disconnect)
}
_ => Ok(Applied::Sent(0)),
}
}
async fn command_loop<W>(
mut command_rx: mpsc::Receiver<ClientCommand>,
protocol: Arc<WhoisClientProtocol>,
write_half: Arc<Mutex<W>>,
sent_query: SentQuery,
client_id: ClientId,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
) where
W: tokio::io::AsyncWrite + Unpin,
{
use crate::llm::actions::protocol_trait::Protocol;
while let Some(command) = command_rx.recv().await {
let action = command.action.clone();
let outcome = match protocol.execute_action(action.clone()) {
Err(e) => Ok(ClientSendOutcome::Rejected {
error: e.to_string(),
}),
Ok(result) => Self::apply_action(result, &write_half, &sent_query, client_id)
.await
.map(|applied| match applied {
Applied::Disconnect => ClientSendOutcome::Disconnected,
Applied::Sent(0) => ClientSendOutcome::Executed {
detail: "executed (nothing to write)".to_string(),
},
Applied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
}),
};
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!("WHOIS 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 {
app_state.remove_client_handle(client_id).await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
break;
}
}
}
}