netget/client/whois/mod.rs
1//! WHOIS client implementation.
2//!
3//! **The peer here is somebody else's server, and it decides how much it says.** A WHOIS
4//! reply has no length field: the server writes until it is done and then closes, so the
5//! client reads until EOF. That makes "read until EOF" the protocol *and* an unbounded
6//! allocation controlled entirely by the remote end — a server that streams, or simply never
7//! stops, grows this process's memory for as long as it keeps sending. Both read paths are
8//! therefore capped by [`MAX_RESPONSE_BYTES`] and bounded by [`RESPONSE_READ_TIMEOUT`], and
9//! an over-long reply is truncated and answered rather than abandoned: the head of a WHOIS
10//! record is the part with the registrar in it.
11
12pub mod actions;
13
14pub use actions::WhoisClientProtocol;
15
16use crate::llm::actions::client_trait::{Client, ClientActionResult};
17use anyhow::{Context, Result};
18use std::net::SocketAddr;
19use std::sync::Arc;
20use std::time::Duration;
21use tokio::io::{AsyncReadExt, AsyncWriteExt};
22use tokio::net::TcpStream;
23use tokio::sync::{mpsc, Mutex};
24use tracing::{debug, error, info, trace, warn};
25
26/// The most of a WHOIS reply this client will hold.
27///
28/// Generous — a thick registry record with a legal notice attached is a few tens of KB — and
29/// far below what an unbounded read costs. The remote server chooses how much it sends; this
30/// is the only thing that chooses how much is kept.
31const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
32
33/// How long to wait for more of a reply before giving up on the server.
34///
35/// Bounds the gap between reads rather than the whole transfer, so a server that is still
36/// sending keeps the connection. Without it a server that accepts, says nothing and never
37/// closes parks this client forever — and the model is waiting on the response event.
38const RESPONSE_READ_TIMEOUT: Duration = Duration::from_secs(60);
39
40/// Strip control characters from a query before it goes on the wire.
41///
42/// RFC 3912 is one line, one query. A `query` containing CR or LF would put a *second* query
43/// line on the wire that the model never asked for, and the client would then attribute the
44/// server's reply to the first — so what reaches the response event would not describe what
45/// was actually asked. `finger`'s client (`strip_controls`) and `gopher`'s (which refuses a
46/// selector containing CR or LF outright) both guard this; WHOIS did not.
47///
48/// ESC goes with them: a query is echoed into the log and onto the operator's dashboard.
49fn sanitize_query(s: &str) -> String {
50 crate::utils::sanitize::strip_controls(s)
51}
52
53/// A WHOIS reply as it came off the socket.
54struct Reply {
55 /// Decoded lossily on purpose: registry servers still emit Latin-1, and `read_to_string`
56 /// (which this replaced on one path) fails a whole transfer over a single byte.
57 text: String,
58 /// [`MAX_RESPONSE_BYTES`] cut the reply short.
59 truncated: bool,
60 /// The socket errored. Reported separately from the text, because the bytes that did
61 /// arrive are still the head of the record.
62 error: Option<std::io::Error>,
63}
64
65/// Read a WHOIS reply to EOF, the cap, or the timeout.
66async fn read_reply<R>(reader: &mut R, client_id: ClientId) -> Reply
67where
68 R: tokio::io::AsyncRead + Unpin,
69{
70 let mut response: Vec<u8> = Vec::new();
71 let mut buf = vec![0u8; 4096];
72 let mut truncated = false;
73 let mut error = None;
74
75 loop {
76 let read = match tokio::time::timeout(RESPONSE_READ_TIMEOUT, reader.read(&mut buf)).await {
77 Err(_) => {
78 warn!(
79 "WHOIS client {} saw no further data for {}s; treating the reply as \
80 complete",
81 client_id,
82 RESPONSE_READ_TIMEOUT.as_secs()
83 );
84 break;
85 }
86 Ok(read) => read,
87 };
88
89 match read {
90 Ok(0) => break,
91 Ok(n) => {
92 let room = MAX_RESPONSE_BYTES.saturating_sub(response.len());
93 if n > room {
94 response.extend_from_slice(&buf[..room]);
95 truncated = true;
96 warn!(
97 "WHOIS client {} reply exceeded {} bytes; keeping the head and \
98 stopping. A WHOIS reply has no length field, so how much arrives is \
99 the remote server's choice, not ours.",
100 client_id, MAX_RESPONSE_BYTES
101 );
102 break;
103 }
104 response.extend_from_slice(&buf[..n]);
105 }
106 Err(e) => {
107 error = Some(e);
108 break;
109 }
110 }
111 }
112
113 Reply {
114 text: String::from_utf8_lossy(&response).into_owned(),
115 truncated,
116 error,
117 }
118}
119
120use crate::client::llm_budget::call_llm_for_client;
121use crate::client::whois::actions::{
122 WHOIS_CLIENT_CONNECTED_EVENT, WHOIS_CLIENT_RESPONSE_RECEIVED_EVENT,
123};
124use crate::llm::ollama_client::OllamaClient;
125use crate::llm::ClientLlmResult;
126use crate::protocol::Event;
127use crate::state::app_state::AppState;
128use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
129use crate::state::{AccessLogOwner, ClientId, ClientStatus};
130
131/// The query that actually went on the wire, whichever path put it there (the model's
132/// `query_whois` or an injected one). Read once the server closes, to raise the
133/// response event with the right `query`.
134type SentQuery = Arc<std::sync::Mutex<Option<String>>>;
135
136/// What [`WhoisClient::apply_action`] did with one action.
137enum Applied {
138 /// Bytes written (0 when the action produced no wire output).
139 Sent(usize),
140 /// The write side was shut down and the session should end.
141 Disconnect,
142}
143
144/// WHOIS client that connects to a WHOIS server
145pub struct WhoisClient;
146
147impl WhoisClient {
148 /// Connect to a WHOIS server with integrated LLM actions
149 pub async fn connect_with_llm_actions(
150 remote_addr: String,
151 llm_client: OllamaClient,
152 app_state: Arc<AppState>,
153 status_tx: mpsc::UnboundedSender<String>,
154 client_id: ClientId,
155 ) -> Result<SocketAddr> {
156 // Connect to WHOIS server
157 let stream = TcpStream::connect(&remote_addr).await.context(format!(
158 "Failed to connect to WHOIS server at {}",
159 remote_addr
160 ))?;
161
162 let local_addr = stream.local_addr()?;
163 let remote_sock_addr = stream.peer_addr()?;
164
165 info!(
166 "WHOIS client {} connected to {} (local: {})",
167 client_id, remote_sock_addr, local_addr
168 );
169
170 // Update client state
171 app_state
172 .update_client_status(client_id, ClientStatus::Connected)
173 .await;
174 let _ = status_tx.send(format!("[CLIENT] WHOIS client {} connected", client_id));
175 let _ = status_tx.send("__UPDATE_UI__".to_string());
176
177 // Split stream
178 let (read_half, write_half) = tokio::io::split(stream);
179 let write_half_arc = Arc::new(Mutex::new(write_half));
180 let protocol = Arc::new(WhoisClientProtocol::new());
181 let sent_query: SentQuery = Arc::new(std::sync::Mutex::new(None));
182
183 // Command channel for injected actions (the dashboard's [ query_whois ] /
184 // [ disconnect ]). Registered BEFORE the connected-event LLM call, which a manual
185 // `*` rule can park for minutes - the operator must be able to send the query
186 // while it waits. The read below is a plain `read()` loop, but the command task is
187 // separate anyway so an injected query never waits on the LLM round-trip.
188 let command_rx =
189 crate::client::command_support::register_command_channel(&app_state, client_id).await;
190 let cmd_task = tokio::spawn(Self::command_loop(
191 command_rx,
192 protocol.clone(),
193 write_half_arc.clone(),
194 sent_query.clone(),
195 client_id,
196 app_state.clone(),
197 status_tx.clone(),
198 ));
199 app_state.register_client_task(client_id, cmd_task).await;
200
201 // Spawn task to handle LLM interaction
202 // Registered with AppState so stop_client can abort this task —
203 // dropping a JoinHandle only detaches it in Tokio.
204 let task_registrar = app_state.clone();
205 let task_handle = tokio::spawn(async move {
206 Self::session(
207 read_half,
208 write_half_arc,
209 protocol,
210 sent_query,
211 remote_addr,
212 llm_client,
213 app_state.clone(),
214 status_tx.clone(),
215 client_id,
216 )
217 .await;
218 // The session is over: drop the handle so the dashboard stops offering
219 // [ send ] and the command task ends with its channel.
220 app_state.remove_client_handle(client_id).await;
221 let _ = status_tx.send("__UPDATE_UI__".to_string());
222 });
223 task_registrar
224 .register_client_task(client_id, task_handle)
225 .await;
226
227 Ok(local_addr)
228 }
229
230 /// Connected event -> (model's) query -> read until the server closes -> response event.
231 #[allow(clippy::too_many_arguments)]
232 async fn session<R, W>(
233 mut read_half: R,
234 write_half: Arc<Mutex<W>>,
235 protocol: Arc<WhoisClientProtocol>,
236 sent_query: SentQuery,
237 remote_addr: String,
238 llm_client: OllamaClient,
239 app_state: Arc<AppState>,
240 status_tx: mpsc::UnboundedSender<String>,
241 client_id: ClientId,
242 ) where
243 R: tokio::io::AsyncRead + Unpin,
244 W: tokio::io::AsyncWrite + Unpin,
245 {
246 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
247 return;
248 };
249
250 // Call LLM with connected event to get initial query
251 let event = Event::new(
252 &WHOIS_CLIENT_CONNECTED_EVENT,
253 serde_json::json!({
254 "remote_addr": remote_addr,
255 }),
256 );
257
258 let memory = app_state
259 .get_memory_for_client(client_id)
260 .await
261 .unwrap_or_default();
262
263 match call_llm_for_client(
264 &llm_client,
265 &app_state,
266 client_id.to_string(),
267 &instruction,
268 &memory,
269 Some(&event),
270 protocol.as_ref(),
271 &status_tx,
272 )
273 .await
274 {
275 Ok(ClientLlmResult {
276 actions,
277 memory_updates,
278 }) => {
279 // Update memory
280 if let Some(mem) = memory_updates {
281 app_state.set_memory_for_client(client_id, mem).await;
282 }
283
284 // Execute actions (should include query_whois)
285 for action in actions {
286 let result = match protocol.execute_action(action) {
287 Ok(result) => result,
288 Err(e) => {
289 error!("WHOIS client {} rejected action: {}", client_id, e);
290 continue;
291 }
292 };
293 match Self::apply_action(result, &write_half, &sent_query, client_id).await {
294 Ok(Applied::Sent(_)) => {}
295 Ok(Applied::Disconnect) => {
296 info!("WHOIS client {} disconnecting before query", client_id);
297 app_state
298 .update_client_status(client_id, ClientStatus::Disconnected)
299 .await;
300 let _ = status_tx.send("__UPDATE_UI__".to_string());
301 return;
302 }
303 Err(e) => {
304 error!("WHOIS client {} failed to send query: {}", client_id, e);
305 app_state
306 .update_client_status(client_id, ClientStatus::Error(e.to_string()))
307 .await;
308 let _ = status_tx.send("__UPDATE_UI__".to_string());
309 return;
310 }
311 }
312 }
313 }
314 Err(e) => {
315 // Stay connected: the operator can still inject the query from the
316 // dashboard, and the server will close on its own otherwise.
317 error!("LLM error for WHOIS client {}: {}", client_id, e);
318 let _ = status_tx.send(format!(
319 "[WARN] WHOIS client {} LLM error: {} (still connected; a query can be \
320 injected)",
321 client_id, e
322 ));
323 }
324 }
325
326 if sent_query.lock().map(|q| q.is_none()).unwrap_or(true) {
327 info!(
328 "WHOIS client {} has no query yet; waiting for an injected one or the server closing",
329 client_id
330 );
331 }
332
333 // Read the full response: WHOIS servers close after sending it (RFC 3912). A
334 // cancellation-safe `read()` loop rather than `read_to_string`, so the shape stays
335 // compatible with a `select!` arm if one is ever needed — and capped, because how
336 // much arrives is the remote server's choice (see the module note).
337 let reply = read_reply(&mut read_half, client_id).await;
338 if let Some(e) = reply.error {
339 error!("WHOIS client {} read error: {}", client_id, e);
340 app_state
341 .update_client_status(client_id, ClientStatus::Error(e.to_string()))
342 .await;
343 let _ = status_tx.send("__UPDATE_UI__".to_string());
344 return;
345 }
346 let response = reply.text;
347 let truncated = reply.truncated;
348 debug!(
349 "WHOIS client {} received {} bytes",
350 client_id,
351 response.len()
352 );
353 trace!("WHOIS response:\n{}", response);
354
355 let query = sent_query.lock().ok().and_then(|q| q.clone());
356 match query {
357 Some(query) => {
358 // Call LLM with response
359 // `truncated` is on the event because a model that read half a record and
360 // thought it had the whole one would answer confidently and wrongly.
361 let event = Event::new(
362 &WHOIS_CLIENT_RESPONSE_RECEIVED_EVENT,
363 serde_json::json!({
364 "response": response,
365 "query": query,
366 "truncated": truncated,
367 }),
368 );
369
370 let memory = app_state
371 .get_memory_for_client(client_id)
372 .await
373 .unwrap_or_default();
374
375 match call_llm_for_client(
376 &llm_client,
377 &app_state,
378 client_id.to_string(),
379 &instruction,
380 &memory,
381 Some(&event),
382 protocol.as_ref(),
383 &status_tx,
384 )
385 .await
386 {
387 Ok(ClientLlmResult {
388 actions,
389 memory_updates,
390 }) => {
391 // Update memory
392 if let Some(mem) = memory_updates {
393 app_state.set_memory_for_client(client_id, mem).await;
394 }
395
396 // Execute what the model asked for. These were discarded, so a
397 // model that read a registry response and wanted to follow the
398 // referral to the registrar -- the chase that is most of what
399 // WHOIS is used for -- was silently ignored.
400 use crate::llm::actions::client_trait::{Client, ClientActionResult};
401 for action in actions {
402 let Ok(ClientActionResult::Custom { name, data }) =
403 protocol.execute_action(action.clone())
404 else {
405 continue;
406 };
407 if name != "whois_query" {
408 continue;
409 }
410 let Some(q) = data.get("query").and_then(|v| v.as_str()) else {
411 continue;
412 };
413 match Self::run_query_once(&remote_addr, q, client_id).await {
414 Ok(resp) => info!(
415 "WHOIS client {} follow-up query {:?} returned {} bytes",
416 client_id,
417 q,
418 resp.len()
419 ),
420 Err(e) => error!(
421 "WHOIS client {} follow-up query {:?} failed: {}",
422 client_id, q, e
423 ),
424 }
425 }
426 }
427 Err(e) => {
428 error!("LLM error for WHOIS client {}: {}", client_id, e);
429 }
430 }
431
432 // WHOIS is one-shot, connection closes after response
433 info!("WHOIS client {} query complete", client_id);
434 }
435 None => {
436 info!(
437 "WHOIS client {} closed by the server before any query was sent",
438 client_id
439 );
440 }
441 }
442
443 app_state
444 .update_client_status(client_id, ClientStatus::Disconnected)
445 .await;
446 let _ = status_tx.send(format!("[CLIENT] WHOIS client {} disconnected", client_id));
447 let _ = status_tx.send("__UPDATE_UI__".to_string());
448 }
449
450 /// Put one executed action on the wire. Shared by the LLM path and injected commands
451 /// so the encoding of `query_whois` exists exactly once.
452 /// Run one WHOIS query on a **fresh** connection and return the response.
453 ///
454 /// Raises no event and calls no LLM.
455 ///
456 /// The new connection is not incidental. RFC 3912 is one query per connection: the
457 /// server answers and closes, and `apply_action` says so itself ("a second one on the
458 /// same connection is outside RFC 3912 and most servers ignore it"). So a follow-up
459 /// the model asks for after reading a response -- the referral chase that is most of
460 /// what WHOIS is for, where the registry tells you which registrar to ask next --
461 /// genuinely needs its own connection.
462 ///
463 /// Raising no event also bounds the chain and keeps the async type non-recursive,
464 /// which is what tokio::spawn's Send bound requires.
465 async fn run_query_once(remote_addr: &str, query: &str, client_id: ClientId) -> Result<String> {
466 use tokio::io::AsyncWriteExt;
467 let mut stream = TcpStream::connect(remote_addr)
468 .await
469 .with_context(|| format!("WHOIS follow-up could not reach {remote_addr}"))?;
470 let query = sanitize_query(query);
471 stream.write_all(format!("{query}\r\n").as_bytes()).await?;
472 stream.flush().await?;
473
474 // Was `read_to_string`, which is unbounded *and* fails the whole transfer on the
475 // first non-UTF-8 byte — and a referral chase points this at registrars nobody here
476 // chose, several of which still emit Latin-1. `read_reply` caps it and decodes
477 // lossily; see the module note.
478 let reply = read_reply(&mut stream, client_id).await;
479 if let Some(e) = reply.error {
480 return Err(e).with_context(|| format!("WHOIS follow-up read from {remote_addr}"));
481 }
482 Ok(reply.text)
483 }
484
485 async fn apply_action<W>(
486 result: ClientActionResult,
487 write_half: &Arc<Mutex<W>>,
488 sent_query: &SentQuery,
489 client_id: ClientId,
490 ) -> Result<Applied>
491 where
492 W: tokio::io::AsyncWrite + Unpin,
493 {
494 match result {
495 ClientActionResult::Custom { name, data } if name == "whois_query" => {
496 let query = sanitize_query(
497 data.get("query")
498 .and_then(|v| v.as_str())
499 .context("Missing query in action data")?,
500 );
501 debug!("WHOIS client {} querying: {}", client_id, query);
502 let query_bytes = format!("{}\r\n", query);
503 {
504 let mut writer = write_half.lock().await;
505 writer.write_all(query_bytes.as_bytes()).await?;
506 writer.flush().await?;
507 }
508 trace!("WHOIS client {} sent query: {}", client_id, query);
509 if let Ok(mut slot) = sent_query.lock() {
510 // The first query is the one the response answers; a second one on the
511 // same connection is outside RFC 3912 and most servers ignore it.
512 slot.get_or_insert(query);
513 }
514 Ok(Applied::Sent(query_bytes.len()))
515 }
516 ClientActionResult::Disconnect => {
517 debug!("WHOIS client {} disconnecting", client_id);
518 // Half-close: the server reads EOF and closes, and the read loop then
519 // sees 0 and runs its normal path.
520 let _ = write_half.lock().await.shutdown().await;
521 Ok(Applied::Disconnect)
522 }
523 // Unknown Custom, WaitForMore, NoAction, SendData, nested Multiple.
524 _ => Ok(Applied::Sent(0)),
525 }
526 }
527
528 /// Drain injected commands until the channel closes (session over, or client removed)
529 /// or an injected `disconnect` ends the session.
530 ///
531 /// The generic `command_support::handle_stream_client_command` cannot run this client's
532 /// vocabulary because `query_whois` yields `ClientActionResult::Custom`, so the action
533 /// goes through [`Self::apply_action`] - the same function the LLM path uses - and the
534 /// outcome is recorded and replied exactly the way the generic arm does it.
535 async fn command_loop<W>(
536 mut command_rx: mpsc::Receiver<ClientCommand>,
537 protocol: Arc<WhoisClientProtocol>,
538 write_half: Arc<Mutex<W>>,
539 sent_query: SentQuery,
540 client_id: ClientId,
541 app_state: Arc<AppState>,
542 status_tx: mpsc::UnboundedSender<String>,
543 ) where
544 W: tokio::io::AsyncWrite + Unpin,
545 {
546 use crate::llm::actions::protocol_trait::Protocol;
547
548 while let Some(command) = command_rx.recv().await {
549 let action = command.action.clone();
550 let outcome = match protocol.execute_action(action.clone()) {
551 Err(e) => Ok(ClientSendOutcome::Rejected {
552 error: e.to_string(),
553 }),
554 Ok(result) => Self::apply_action(result, &write_half, &sent_query, client_id)
555 .await
556 .map(|applied| match applied {
557 Applied::Disconnect => ClientSendOutcome::Disconnected,
558 Applied::Sent(0) => ClientSendOutcome::Executed {
559 detail: "executed (nothing to write)".to_string(),
560 },
561 Applied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
562 }),
563 };
564
565 let outcome_json = match &outcome {
566 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
567 Err(e) => serde_json::json!({"error": e.to_string()}),
568 };
569 app_state
570 .record_access_log(
571 AccessLogOwner::Client(client_id.as_u32()),
572 protocol.protocol_name(),
573 None,
574 "injected_action",
575 action,
576 vec![outcome_json],
577 )
578 .await;
579
580 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
581 if let Err(e) = &outcome {
582 error!("WHOIS client {} injected action failed: {}", client_id, e);
583 let _ = status_tx.send(format!(
584 "[WARN] Client {} injected action failed: {}",
585 client_id, e
586 ));
587 }
588 let _ = status_tx.send("__UPDATE_UI__".to_string());
589 crate::client::command_support::reply(command, outcome);
590
591 if disconnect {
592 // Do not wait for the server to answer the half-close with its own FIN:
593 // the rail must stop offering [ send ] on this client now.
594 app_state.remove_client_handle(client_id).await;
595 let _ = status_tx.send("__UPDATE_UI__".to_string());
596 break;
597 }
598 }
599 }
600}