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}