Skip to main content

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}