Skip to main content

netget/server/whois/
mod.rs

1//! WHOIS (RFC 3912) server.
2//!
3//! The client sends one line, the server answers with free text. Three properties of the read
4//! loop are worth knowing before changing it, because none of them is obvious from the RFC:
5//!
6//! 1. **A query is a *line*, not a TCP segment.** The loop used to raise one `whois_query`
7//!    event per `read()`, so a query split across two segments became two events with two
8//!    partial queries, and a peer dripping one byte at a time bought one LLM call per byte.
9//!    That is unmetered work for anyone who can open a socket, so reads now accumulate to a
10//!    newline under [`MAX_QUERY_BYTES`].
11//! 2. **Reads are bounded in time as well as size.** A peer that connects and says nothing
12//!    otherwise holds a task and a connection slot for as long as it likes.
13//! 3. **The idle timeout is what rescues a real client from this server's one known
14//!    non-conformance.** RFC 3912 has the server close as soon as its output is finished, and
15//!    `whois(1)` reads until EOF — but this server keeps reading so several queries can share
16//!    a connection, so a handler that answers without `close_connection` would otherwise
17//!    block a real client forever. It now blocks for [`IDLE_AFTER_REPLY_TIMEOUT`] instead.
18//!    Pairing the answer with `close_connection` is still the right thing to do, and is what
19//!    both `send_*` action descriptions say.
20pub mod actions;
21
22use crate::llm::action_helper::call_llm;
23use crate::llm::ollama_client::OllamaClient;
24use crate::logging::emit::Log;
25use crate::protocol::Event;
26use crate::server::connection::ConnectionId;
27use crate::state::app_state::AppState;
28use actions::WHOIS_QUERY_EVENT;
29use anyhow::Result;
30use std::net::SocketAddr;
31use std::sync::Arc;
32use std::time::Duration;
33use tokio::io::{AsyncReadExt, AsyncWriteExt};
34use tokio::net::TcpListener;
35use tokio::sync::{mpsc, Mutex};
36
37/// A WHOIS query is a domain, a handle or an IP. Anything past this is not one, and buffering
38/// it for a peer who may never send a newline is a memory hole reachable by anyone.
39pub const MAX_QUERY_BYTES: usize = 4096;
40
41/// How long to wait for the first query from a peer that has only connected.
42///
43/// **This was 30 seconds and it strands NetGet's own client.** WHOIS is client-speaks-first and
44/// the server says nothing until a line arrives, so thirty seconds was argued — correctly — about
45/// a stranger holding an unauthenticated socket. The peer this server most often has is not a
46/// stranger: `src/client/whois/mod.rs` opens the socket and sends **nothing**, raising
47/// `whois_client_connected` and waiting for the model or for a person to supply the query. Its
48/// own comment says so where it registers the command channel — early, "because a manual `*`
49/// rule can park [the connected event] for minutes - the operator must be able to send the
50/// query while it waits". The dashboard's `[ + whois client ]` gives that connect event a
51/// zero-action static rule, so the client is answered with nothing and waits at zero bytes sent
52/// for as long as the operator takes to type a domain into `[ send message ]`. Thirty seconds
53/// is less than a person takes.
54///
55/// 300 seconds is the window a `manual` rule gives a human to answer one event
56/// (`src/state/intercepts.rs`). The cost is that a stranger holds a socket and a task for 300
57/// seconds instead of 30 — still bounded, still capped by the accept loop, and a WHOIS
58/// connection carries nothing but a [`MAX_QUERY_BYTES`] line buffer. A listener genuinely
59/// exposed to strangers should set `first_byte_timeout_secs` back down.
60const FIRST_QUERY_READ_TIMEOUT: Duration = Duration::from_secs(300);
61
62/// How long to wait for a *further* query after one has been answered.
63///
64/// Shorter than the first wait on purpose: RFC 3912 expects the connection to be over at this
65/// point, so anyone still holding it open is the exception. See the module note — this is the
66/// bound that turns "blocked forever" into "blocked briefly" for a client whose handler forgot
67/// `close_connection`.
68///
69/// **Deliberately not raised to 300 alongside the first bound, and the asymmetry is the point.**
70/// Everywhere else in this tree the idle bound is the more generous of the two; here it is the
71/// stricter, because it is not really an idle bound at all — it is a repair for this server's
72/// one known non-conformance. RFC 3912 has the server close when its output is finished and
73/// `whois(1)` reads until EOF, so when a handler answers without `close_connection` this bound
74/// is the *only* thing that ever unblocks the client. Lengthening it to 300 would make that
75/// rescue twenty times slower for every real WHOIS client in exchange for a second query nobody
76/// composes by hand inside a session the protocol says is already over — the right answer for a
77/// human issuing several queries down one socket is to raise `idle_timeout_secs`, which is what
78/// the parameter is for, not to make everyone wait.
79const IDLE_AFTER_REPLY_TIMEOUT: Duration = Duration::from_secs(15);
80
81/// Concurrent connections this server admits before it starts refusing.
82///
83/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. This is the "still capped by the
84/// accept loop" the [`FIRST_QUERY_READ_TIMEOUT`] note above leans on, and until now that
85/// sentence was describing something that did not exist. The pairing matters here more than
86/// most: that bound is 300 seconds precisely so NetGet's own client is not stranded, which is
87/// 300 seconds of held socket per stranger, and a per-connection bound multiplied by an
88/// unbounded number of connections is not a bound. A WHOIS connection costs a
89/// [`MAX_QUERY_BYTES`] line buffer and a task, so the house default is the right number.
90const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
91
92/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
93///
94/// WHOIS (RFC 3912) has no status codes at all: a reply is free text the client prints and the
95/// server closes. `%` is the RPSL comment prefix every registry uses for lines that are not
96/// data, and `% netget:` is already how this server labels its own refusals — "query too long",
97/// "backend at capacity, retry later". A `whois(1)` client reads until EOF and prints this; it
98/// cannot be mistaken for an answer, because it says it is not one.
99const CONNECTION_CAP_REFUSAL: &[u8] = b"% netget: too many connections, retry later\r\n";
100
101pub struct WhoisServer;
102
103impl WhoisServer {
104    /// Spawn WHOIS server with integrated LLM actions
105    pub async fn spawn_with_llm_actions(
106        listen_addr: SocketAddr,
107        llm_client: OllamaClient,
108        app_state: Arc<AppState>,
109        status_tx: mpsc::UnboundedSender<String>,
110        server_id: crate::state::ServerId,
111        first_byte_timeout_secs: Option<u64>,
112        idle_timeout_secs: Option<u64>,
113    ) -> Result<SocketAddr> {
114        // Both read bounds are tunable because their right value is a property of who is on the
115        // other end, which only the operator knows. The default first bound serves NetGet's own
116        // client waiting on a human; a listener exposed to strangers wants it much lower.
117        let first_query_timeout = first_byte_timeout_secs
118            .map(Duration::from_secs)
119            .unwrap_or(FIRST_QUERY_READ_TIMEOUT);
120        let idle_timeout = idle_timeout_secs
121            .map(Duration::from_secs)
122            .unwrap_or(IDLE_AFTER_REPLY_TIMEOUT);
123        let listener = TcpListener::bind(listen_addr).await?;
124        let local_addr = listener.local_addr()?;
125
126        Log::new(Some(&status_tx)).info(format!("WHOIS server listening on {}", local_addr));
127
128        let protocol = Arc::new(actions::WhoisProtocol::new());
129
130        let task_registrar = app_state.clone();
131        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
132        let accept_handle = tokio::spawn(async move {
133            loop {
134                match crate::server::accept_bounded::accept_bounded(
135                    &listener,
136                    &limiter,
137                    CONNECTION_CAP_REFUSAL,
138                    "WHOIS",
139                    Some(&status_tx),
140                )
141                .await
142                {
143                    Ok((socket, peer_addr, permit)) => {
144                        let connection_id =
145                            ConnectionId::new(app_state.get_next_unified_id().await);
146
147                        // Add connection to ServerInstance
148                        use crate::state::server::{
149                            ConnectionState as ServerConnectionState, ConnectionStatus,
150                            ProtocolConnectionInfo,
151                        };
152                        let now = crate::utils::clock::Instant::now();
153                        let conn_state = ServerConnectionState {
154                            id: connection_id,
155                            remote_addr: peer_addr,
156                            local_addr,
157                            bytes_sent: 0,
158                            bytes_received: 0,
159                            packets_sent: 0,
160                            packets_received: 0,
161                            last_activity: now,
162                            status: ConnectionStatus::Active,
163                            status_changed_at: now,
164                            protocol_info: ProtocolConnectionInfo::empty(),
165                        };
166                        app_state
167                            .add_connection_to_server(server_id, conn_state)
168                            .await;
169                        let _ = status_tx.send("__UPDATE_UI__".to_string());
170
171                        Log::new(Some(&status_tx))
172                            .info(format!("WHOIS client connected from {}", peer_addr));
173
174                        let llm_clone = llm_client.clone();
175                        let state_clone = app_state.clone();
176                        let status_clone = status_tx.clone();
177                        let protocol_clone = protocol.clone();
178                        let connection_id_clone = connection_id;
179
180                        // Tracked, not detached: stop_server must abort this task too.
181                        let task_owner = app_state.clone();
182                        task_owner
183                            .spawn_server_task(server_id, async move {
184                                // Released when this task ends, and this task is the whole of
185                                // the connection — WHOIS spawns nothing else per peer — so
186                                // `MAX_CONNECTIONS` caps live connections, not accepts.
187                                let _permit = permit;
188                                handle_whois_connection(
189                                    socket,
190                                    peer_addr,
191                                    llm_clone,
192                                    state_clone,
193                                    status_clone,
194                                    server_id,
195                                    protocol_clone,
196                                    connection_id_clone,
197                                    first_query_timeout,
198                                    idle_timeout,
199                                )
200                                .await
201                            })
202                            .await;
203                    }
204                    Err(e) => {
205                        Log::new(Some(&status_tx)).error(format!("WHOIS accept error: {}", e));
206                        break;
207                    }
208                }
209            }
210        });
211
212        task_registrar
213            .register_server_task(server_id, accept_handle)
214            .await;
215
216        Ok(local_addr)
217    }
218}
219
220/// Write one reply to the peer and count it. The guard is dropped before the
221/// stats update so nothing awaits while holding the write half.
222async fn write_counted<W>(
223    write_half: &Arc<Mutex<W>>,
224    data: &[u8],
225    app_state: &AppState,
226    server_id: crate::state::ServerId,
227    connection_id: ConnectionId,
228) -> std::io::Result<()>
229where
230    W: tokio::io::AsyncWrite + Unpin,
231{
232    {
233        let mut writer = write_half.lock().await;
234        writer.write_all(data).await?;
235        writer.flush().await?;
236    }
237    app_state
238        .update_connection_stats(
239            server_id,
240            connection_id,
241            None,
242            Some(data.len() as u64),
243            None,
244            Some(1),
245        )
246        .await;
247    Ok(())
248}
249
250#[allow(clippy::too_many_arguments)]
251async fn handle_whois_connection(
252    socket: tokio::net::TcpStream,
253    peer_addr: SocketAddr,
254    llm_client: OllamaClient,
255    app_state: Arc<AppState>,
256    status_tx: mpsc::UnboundedSender<String>,
257    server_id: crate::state::ServerId,
258    protocol: Arc<actions::WhoisProtocol>,
259    connection_id: ConnectionId,
260    first_query_timeout: Duration,
261    idle_timeout: Duration,
262) {
263    let (reader, write_half) = tokio::io::split(socket);
264    let write_half = Arc::new(Mutex::new(write_half));
265
266    // Peer messaging: the dashboard's "message this peer" / "disconnect this peer" inject
267    // actions into THIS connection through the same executor the LLM path uses. Registered
268    // before the first read, because a WHOIS server says nothing until the client speaks
269    // and a manual `*` rule can then park the query for minutes - the operator must be
270    // able to reach the connection while it waits.
271    let peer_rx = crate::server::peer_support::register_peer_channel(
272        &app_state,
273        server_id,
274        connection_id.as_u32(),
275    )
276    .await;
277    crate::server::peer_support::spawn_peer_command_task(
278        peer_rx,
279        protocol.clone(),
280        app_state.clone(),
281        server_id,
282        connection_id.as_u32(),
283        write_half.clone(),
284        status_tx.clone(),
285    );
286
287    run_whois_session(
288        reader,
289        &write_half,
290        peer_addr,
291        &llm_client,
292        &app_state,
293        &status_tx,
294        server_id,
295        &protocol,
296        connection_id,
297        first_query_timeout,
298        idle_timeout,
299    )
300    .await;
301
302    // Every exit path - EOF, read error, write error, close_connection, LLM failure - lands
303    // here. Dropping the handle also ends the peer command task, which releases its clone of
304    // the write half; the explicit shutdown makes the FIN immediate rather than waiting on it.
305    app_state
306        .remove_peer_handle(server_id, connection_id.as_u32())
307        .await;
308    let _ = write_half.lock().await.shutdown().await;
309
310    use crate::state::server::ConnectionStatus;
311    app_state
312        .update_connection_status(server_id, connection_id, ConnectionStatus::Closed)
313        .await;
314    let _ = status_tx.send("__UPDATE_UI__".to_string());
315}
316
317#[allow(clippy::too_many_arguments)]
318async fn run_whois_session<R, W>(
319    mut reader: R,
320    write_half: &Arc<Mutex<W>>,
321    peer_addr: SocketAddr,
322    llm_client: &OllamaClient,
323    app_state: &Arc<AppState>,
324    status_tx: &mpsc::UnboundedSender<String>,
325    server_id: crate::state::ServerId,
326    protocol: &Arc<actions::WhoisProtocol>,
327    connection_id: ConnectionId,
328    first_query_timeout: Duration,
329    idle_timeout: Duration,
330) where
331    R: tokio::io::AsyncRead + Unpin,
332    W: tokio::io::AsyncWrite + Unpin,
333{
334    let log = Log::new(Some(status_tx));
335    let mut lines = QueryLineReader::new(&mut reader);
336    let mut answered_one = false;
337
338    loop {
339        let read_timeout = if answered_one {
340            idle_timeout
341        } else {
342            first_query_timeout
343        };
344
345        let (query, n) = match lines.next_query(read_timeout).await {
346            QueryRead::Line(line, n) => (line, n),
347            QueryRead::Eof => {
348                log.info(format!("WHOIS client {} disconnected", peer_addr));
349                break;
350            }
351            QueryRead::TimedOut => {
352                log.info(format!(
353                    "WHOIS client {} sent nothing further within {}s; closing",
354                    peer_addr,
355                    read_timeout.as_secs()
356                ));
357                break;
358            }
359            QueryRead::TooLong => {
360                // A '%' line is a comment in every WHOIS dialect, so a client reads this as a
361                // remark and never as a record. Fixed text, so there is no placeholder an
362                // internal error could reach.
363                log.warn(format!(
364                    "WHOIS query from {} exceeded {} bytes with no newline; refusing",
365                    peer_addr, MAX_QUERY_BYTES
366                ));
367                let _ = write_counted(
368                    write_half,
369                    b"% netget: query too long\r\n",
370                    app_state,
371                    server_id,
372                    connection_id,
373                )
374                .await;
375                break;
376            }
377            QueryRead::Failed(e) => {
378                log.error(format!("WHOIS read error from {}: {}", peer_addr, e));
379                break;
380            }
381        };
382
383        app_state
384            .update_connection_stats(
385                server_id,
386                connection_id,
387                Some(n as u64),
388                None,
389                Some(1),
390                None,
391            )
392            .await;
393
394        // Summary + payload are FileOnly: the whois_query event template surfaces the query
395        // to the TUI.
396        log.debug(format!("WHOIS received {} bytes from {}", n, peer_addr));
397        log.trace(format!("WHOIS query data: {}", query));
398
399        let event = Event::new(
400            &WHOIS_QUERY_EVENT,
401            serde_json::json!({
402                "query": query,
403            }),
404        );
405
406        log.debug(format!("WHOIS calling LLM for query from {}", peer_addr));
407
408        match call_llm(
409            llm_client,
410            app_state,
411            server_id,
412            Some(connection_id),
413            &event,
414            protocol.as_ref(),
415        )
416        .await
417        {
418            Ok(execution_result) => {
419                for message in &execution_result.messages {
420                    log.info(message);
421                }
422
423                log.debug(format!(
424                    "WHOIS got {} protocol results",
425                    execution_result.protocol_results.len()
426                ));
427
428                // Send all outputs to the client and note whether a close was asked for.
429                //
430                // WHOIS is one query, one response, then close (RFC 3912), so a connection
431                // that closes without writing anything is indistinguishable to the client
432                // from a server that is broken. Track whether anything reached the wire and
433                // answer below if nothing did.
434                let mut should_close = false;
435                let mut wrote_output = false;
436                for protocol_result in execution_result.protocol_results {
437                    match protocol_result {
438                        crate::llm::actions::protocol_trait::ActionResult::Output(output_data) => {
439                            if let Err(e) = write_counted(
440                                write_half,
441                                &output_data,
442                                app_state,
443                                server_id,
444                                connection_id,
445                            )
446                            .await
447                            {
448                                log.error(format!("WHOIS write error: {}", e));
449                                return;
450                            }
451                            wrote_output = true;
452
453                            // Summary + payload FileOnly; access line below on TUI.
454                            log.debug(format!(
455                                "WHOIS sent {} bytes to {}",
456                                output_data.len(),
457                                peer_addr
458                            ));
459                            log.trace(format!(
460                                "WHOIS response: {}",
461                                String::from_utf8_lossy(&output_data)
462                            ));
463                            log.info(format!(
464                                "WHOIS response to {} ({} bytes)",
465                                peer_addr,
466                                output_data.len()
467                            ));
468                        }
469                        crate::llm::actions::protocol_trait::ActionResult::CloseConnection => {
470                            should_close = true;
471                            log.debug("WHOIS closing connection per LLM request");
472                        }
473                        _ => {} // Ignore other action results
474                    }
475                }
476
477                // Nothing reached the wire: the model answered with only a close, or with
478                // actions that all failed. Say so in a WHOIS comment line rather than hanging
479                // up silently — '%' is the conventional comment marker, so a client reads it
480                // as a remark and never as a record.
481                if !wrote_output {
482                    log.warn(format!(
483                        "WHOIS {:?} from {} decision=model_silent ({} failed action(s)); \
484                         answering with a comment instead of closing silently",
485                        query,
486                        peer_addr,
487                        execution_result.failures.len()
488                    ));
489                    let notice = b"% netget: no data was produced for this query\r\n";
490                    if write_counted(write_half, notice, app_state, server_id, connection_id)
491                        .await
492                        .is_err()
493                    {
494                        return;
495                    }
496                }
497
498                answered_one = true;
499                if should_close {
500                    break;
501                }
502            }
503            Err(e) => {
504                // The peer gets a category, the log gets the error. Two fixed byte literals
505                // rather than one, because WHOIS has no status code and a comment line is the
506                // only place the distinction can live at all: a client (or the human reading
507                // it) should know whether to come back. Byte literals, so there is no
508                // placeholder anything derived from `e` could reach —
509                // `crate::utils::WireFailure` exists for exactly this, and returns
510                // `&'static str` for the same reason.
511                let (category, notice): (&str, &[u8]) =
512                    match crate::utils::WireFailure::classify(&e) {
513                        crate::utils::WireFailure::Overloaded => (
514                            "overloaded",
515                            b"% netget: backend at capacity, retry later\r\n",
516                        ),
517                        crate::utils::WireFailure::Unavailable => (
518                            "unavailable",
519                            b"% netget: the query could not be answered\r\n",
520                        ),
521                    };
522                log.warn(format!(
523                    "WHOIS {:?} from {} decision=fail_closed_llm_error category={}",
524                    query, peer_addr, category
525                ));
526                log.debug(format!("WHOIS LLM call failed: {}", e));
527                let _ =
528                    write_counted(write_half, notice, app_state, server_id, connection_id).await;
529                break;
530            }
531        }
532    }
533}
534
535/// What one attempt to read a query line produced.
536enum QueryRead {
537    /// A query (trimmed) and the wire bytes it consumed.
538    Line(String, usize),
539    /// The peer hung up with nothing pending.
540    Eof,
541    /// Nothing arrived within the caller's deadline.
542    TimedOut,
543    /// [`MAX_QUERY_BYTES`] arrived with no newline in them.
544    TooLong,
545    /// The socket errored.
546    Failed(std::io::Error),
547}
548
549/// Accumulates reads into whole lines.
550///
551/// A WHOIS query is a line, and the previous loop treated each `read()` as one — so a query
552/// split across two TCP segments raised two `whois_query` events carrying two fragments, and a
553/// peer sending a byte at a time bought one LLM call per byte from an unauthenticated socket.
554/// Leftovers after the newline are kept, because a client may send a second query.
555struct QueryLineReader<'a, R> {
556    reader: &'a mut R,
557    pending: Vec<u8>,
558    chunk: Vec<u8>,
559}
560
561impl<'a, R: tokio::io::AsyncRead + Unpin> QueryLineReader<'a, R> {
562    fn new(reader: &'a mut R) -> Self {
563        Self {
564            reader,
565            pending: Vec::new(),
566            chunk: vec![0u8; 1024],
567        }
568    }
569
570    /// The `timeout` bounds the wait for *more bytes*, not the whole line: a peer that is
571    /// still sending keeps the connection alive, which is what a client on a slow link needs.
572    async fn next_query(&mut self, timeout: Duration) -> QueryRead {
573        loop {
574            if let Some(idx) = self.pending.iter().position(|b| *b == b'\n') {
575                let consumed = idx + 1;
576                let line: Vec<u8> = self.pending.drain(..consumed).collect();
577                return QueryRead::Line(
578                    String::from_utf8_lossy(&line[..idx]).trim().to_string(),
579                    consumed,
580                );
581            }
582            if self.pending.len() > MAX_QUERY_BYTES {
583                return QueryRead::TooLong;
584            }
585
586            let read = match tokio::time::timeout(timeout, self.reader.read(&mut self.chunk)).await
587            {
588                Err(_) => return QueryRead::TimedOut,
589                Ok(read) => read,
590            };
591
592            match read {
593                Ok(0) => {
594                    if self.pending.is_empty() {
595                        return QueryRead::Eof;
596                    }
597                    // A query with no terminator, followed by a half-close. Real clients send
598                    // CRLF, but answering is better than dropping someone who did not.
599                    let line = std::mem::take(&mut self.pending);
600                    let consumed = line.len();
601                    return QueryRead::Line(
602                        String::from_utf8_lossy(&line).trim().to_string(),
603                        consumed,
604                    );
605                }
606                Ok(n) => self.pending.extend_from_slice(&self.chunk[..n]),
607                Err(e) => return QueryRead::Failed(e),
608            }
609        }
610    }
611}