netget/server/gopher/mod.rs
1//! Gopher (RFC 1436) server.
2//!
3//! One connection, one selector line, one reply, then the server closes — which is what
4//! RFC 1436 specifies ("the server sends the requested item and then closes the connection")
5//! and what every real client needs, because a Gopher reply carries no length. `curl` reads
6//! until EOF and exits 28 if the server does not hang up. WHOIS in this repo keeps reading
7//! instead and documents the resulting hang as a known non-conformance;
8//! `src/server/gopher/CLAUDE.md` explains why this one does not repeat it.
9pub mod actions;
10
11use crate::llm::action_helper::call_llm;
12use crate::llm::ollama_client::OllamaClient;
13use crate::logging::emit::Log;
14use crate::protocol::Event;
15use crate::server::connection::ConnectionId;
16use crate::state::app_state::AppState;
17use actions::GOPHER_REQUEST_EVENT;
18use anyhow::Result;
19use std::net::SocketAddr;
20use std::sync::Arc;
21use std::time::Duration;
22use tokio::io::{AsyncReadExt, AsyncWriteExt};
23use tokio::net::TcpListener;
24use tokio::sync::{mpsc, Mutex};
25
26/// A selector line longer than this is not a Gopher request. RFC 1436 puts no limit on it,
27/// but an unbounded read is a memory hole reachable by anyone who can open a socket.
28pub const MAX_REQUEST_BYTES: usize = 8192;
29
30/// How long to wait for the selector line.
31///
32/// The size cap alone does not bound a peer that connects and stays quiet, or one that sends
33/// a byte every minute: both hold a task, a socket and a connection row indefinitely, and
34/// neither has authenticated. Gopher's exchange is one line in and one reply out, so a client
35/// with nothing to say has nothing to wait for.
36const SELECTOR_READ_TIMEOUT: Duration = Duration::from_secs(30);
37
38/// Concurrent connections this server admits before it starts refusing.
39///
40/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. [`SELECTOR_READ_TIMEOUT`]
41/// bounds how long one silent peer holds a slot; only a cap bounds how many of them there can
42/// be at once. A Gopher connection carries one selector and one reply, so nothing about it
43/// argues for a number other than the house default.
44const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
45
46/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
47///
48/// A type-3 error item, byte-for-byte the shape [`gopher_error_item`] already writes for every
49/// other refusal here: RFC 1436 §3.8 makes type 3 the way a Gopher server says "this did not
50/// work", and it is the only refusal the protocol has. A menu client renders it as an error
51/// line; `curl`, which reads until EOF, prints it and exits 0 rather than 28. It has to be a
52/// literal rather than a `gopher_error_item` call because the refusal is written before any
53/// connection task exists, and `accept_bounded` takes bytes.
54const CONNECTION_CAP_REFUSAL: &[u8] =
55 b"3Too many connections, try again later\t\terror.host\t1\r\n.\r\n";
56
57pub struct GopherServer;
58
59impl GopherServer {
60 /// Bind, then serve. Returns `Err` if the bind fails so `server_startup` can set
61 /// `ServerStatus::Error` rather than reporting a server that never came up.
62 pub async fn spawn_with_llm_actions(
63 listen_addr: SocketAddr,
64 llm_client: OllamaClient,
65 app_state: Arc<AppState>,
66 status_tx: mpsc::UnboundedSender<String>,
67 server_id: crate::state::ServerId,
68 ) -> Result<SocketAddr> {
69 let listener = TcpListener::bind(listen_addr).await?;
70 let local_addr = listener.local_addr()?;
71
72 Log::new(Some(&status_tx)).info(format!("Gopher server listening on {}", local_addr));
73
74 let protocol = Arc::new(actions::GopherProtocol::new());
75
76 let task_registrar = app_state.clone();
77 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
78 let accept_handle = tokio::spawn(async move {
79 loop {
80 match crate::server::accept_bounded::accept_bounded(
81 &listener,
82 &limiter,
83 CONNECTION_CAP_REFUSAL,
84 "Gopher",
85 Some(&status_tx),
86 )
87 .await
88 {
89 Ok((socket, peer_addr, permit)) => {
90 let connection_id =
91 ConnectionId::new(app_state.get_next_unified_id().await);
92
93 use crate::state::server::{
94 ConnectionState as ServerConnectionState, ConnectionStatus,
95 ProtocolConnectionInfo,
96 };
97 let now = crate::utils::clock::Instant::now();
98 let conn_state = ServerConnectionState {
99 id: connection_id,
100 remote_addr: peer_addr,
101 local_addr,
102 bytes_sent: 0,
103 bytes_received: 0,
104 packets_sent: 0,
105 packets_received: 0,
106 last_activity: now,
107 status: ConnectionStatus::Active,
108 status_changed_at: now,
109 protocol_info: ProtocolConnectionInfo::empty(),
110 };
111 app_state
112 .add_connection_to_server(server_id, conn_state)
113 .await;
114 let _ = status_tx.send("__UPDATE_UI__".to_string());
115
116 Log::new(Some(&status_tx))
117 .info(format!("Gopher client connected from {}", peer_addr));
118
119 let llm_clone = llm_client.clone();
120 let state_clone = app_state.clone();
121 let status_clone = status_tx.clone();
122 let protocol_clone = protocol.clone();
123
124 let conn_handle = tokio::spawn(async move {
125 // Released when this task ends, and this task is the whole of the
126 // connection — Gopher spawns nothing else per peer — so
127 // `MAX_CONNECTIONS` caps live connections, not accepts.
128 let _permit = permit;
129 handle_gopher_connection(
130 socket,
131 peer_addr,
132 llm_clone,
133 state_clone,
134 status_clone,
135 server_id,
136 protocol_clone,
137 connection_id,
138 )
139 .await
140 });
141
142 // Register the per-connection task too, not just the accept loop:
143 // `register_server_task` prunes finished handles on every call, so a
144 // one-request-per-connection protocol accumulates nothing, and
145 // `stop_server` then actually cancels a connection parked on a manual
146 // handler instead of leaving it running.
147 app_state.register_server_task(server_id, conn_handle).await;
148 }
149 Err(e) => {
150 Log::new(Some(&status_tx)).error(format!("Gopher accept error: {}", e));
151 break;
152 }
153 }
154 }
155 });
156
157 task_registrar
158 .register_server_task(server_id, accept_handle)
159 .await;
160
161 Ok(local_addr)
162 }
163}
164
165/// Write one reply and count it. The guard is dropped before the stats update, so nothing
166/// awaits an `AppState` lock while holding the write half.
167async fn write_counted<W>(
168 write_half: &Arc<Mutex<W>>,
169 data: &[u8],
170 app_state: &AppState,
171 server_id: crate::state::ServerId,
172 connection_id: ConnectionId,
173) -> std::io::Result<()>
174where
175 W: tokio::io::AsyncWrite + Unpin,
176{
177 {
178 let mut writer = write_half.lock().await;
179 writer.write_all(data).await?;
180 writer.flush().await?;
181 }
182 app_state
183 .update_connection_stats(
184 server_id,
185 connection_id,
186 None,
187 Some(data.len() as u64),
188 None,
189 Some(1),
190 )
191 .await;
192 Ok(())
193}
194
195/// The type-3 error item netget writes when it cannot ask the model, or when the model's
196/// answer produced nothing.
197///
198/// `text` is a `&'static str` from `crate::utils::WireFailure` — a category, never the error
199/// itself. Interpolating the backend's message here would put netget's model name, backend
200/// URL and retry machinery on a stranger's screen. Callers pass the *prefixed* form, which
201/// already names netget: a `format!` that prepended the name here would be a peer-visible
202/// template one edit away from carrying the error too, and `tests/wire_failure_test.rs`
203/// rejects exactly that shape.
204fn wire_failure_item(text: &'static str) -> Vec<u8> {
205 format!("3{}\t\terror.host\t1\r\n.\r\n", text).into_bytes()
206}
207
208#[allow(clippy::too_many_arguments)]
209async fn handle_gopher_connection(
210 socket: tokio::net::TcpStream,
211 peer_addr: SocketAddr,
212 llm_client: OllamaClient,
213 app_state: Arc<AppState>,
214 status_tx: mpsc::UnboundedSender<String>,
215 server_id: crate::state::ServerId,
216 protocol: Arc<actions::GopherProtocol>,
217 connection_id: ConnectionId,
218) {
219 let (reader, write_half) = tokio::io::split(socket);
220 let write_half = Arc::new(Mutex::new(write_half));
221
222 // Peer messaging is registered before the first read: a Gopher server says nothing until
223 // the selector arrives, and a manual `*` rule parks that request for as long as the
224 // operator takes to answer it. Without a handle in place first, the dashboard's
225 // "message this peer" is greyed out for exactly the window in which it is wanted.
226 let peer_rx = crate::server::peer_support::register_peer_channel(
227 &app_state,
228 server_id,
229 connection_id.as_u32(),
230 )
231 .await;
232 crate::server::peer_support::spawn_peer_command_task(
233 peer_rx,
234 protocol.clone(),
235 app_state.clone(),
236 server_id,
237 connection_id.as_u32(),
238 write_half.clone(),
239 status_tx.clone(),
240 );
241
242 run_gopher_session(
243 reader,
244 &write_half,
245 peer_addr,
246 &llm_client,
247 &app_state,
248 &status_tx,
249 server_id,
250 &protocol,
251 connection_id,
252 )
253 .await;
254
255 // Every exit path lands here. Dropping the peer handle ends the peer command task, which
256 // releases its clone of the write half; the explicit shutdown makes the FIN immediate,
257 // and the FIN is the whole protocol - it is how the client learns the reply is complete.
258 app_state
259 .remove_peer_handle(server_id, connection_id.as_u32())
260 .await;
261 let _ = write_half.lock().await.shutdown().await;
262
263 use crate::state::server::ConnectionStatus;
264 app_state
265 .update_connection_status(server_id, connection_id, ConnectionStatus::Closed)
266 .await;
267 let _ = status_tx.send("__UPDATE_UI__".to_string());
268}
269
270/// Read the selector line, ask whoever is answering, write the reply. Returns when the
271/// connection should close — which, for Gopher, is always after one reply.
272#[allow(clippy::too_many_arguments)]
273async fn run_gopher_session<R, W>(
274 mut reader: R,
275 write_half: &Arc<Mutex<W>>,
276 peer_addr: SocketAddr,
277 llm_client: &OllamaClient,
278 app_state: &Arc<AppState>,
279 status_tx: &mpsc::UnboundedSender<String>,
280 server_id: crate::state::ServerId,
281 protocol: &Arc<actions::GopherProtocol>,
282 connection_id: ConnectionId,
283) where
284 R: tokio::io::AsyncRead + Unpin,
285 W: tokio::io::AsyncWrite + Unpin,
286{
287 let log = Log::new(Some(status_tx));
288
289 // --- Read exactly one request line -------------------------------------------------
290 let mut request = Vec::new();
291 let mut chunk = vec![0u8; 1024];
292 let line_end = loop {
293 let read = match tokio::time::timeout(SELECTOR_READ_TIMEOUT, reader.read(&mut chunk)).await
294 {
295 Err(_) => {
296 // Nothing to answer: no selector arrived, so there is no request. Returning
297 // closes the socket, which is the only thing the peer was owed.
298 log.info(format!(
299 "Gopher client {} sent no selector within {}s; closing",
300 peer_addr,
301 SELECTOR_READ_TIMEOUT.as_secs()
302 ));
303 return;
304 }
305 Ok(read) => read,
306 };
307
308 match read {
309 Ok(0) => {
310 log.info(format!(
311 "Gopher client {} disconnected before sending a selector",
312 peer_addr
313 ));
314 return;
315 }
316 Ok(n) => {
317 request.extend_from_slice(&chunk[..n]);
318 app_state
319 .update_connection_stats(
320 server_id,
321 connection_id,
322 Some(n as u64),
323 None,
324 Some(1),
325 None,
326 )
327 .await;
328
329 if let Some(pos) = request.iter().position(|b| *b == b'\n') {
330 break pos;
331 }
332 if request.len() > MAX_REQUEST_BYTES {
333 // Refused by the protocol, before any model call: a distinct outcome from
334 // anything the model did or failed to do, so it carries its own token.
335 log.warn(format!(
336 "Gopher request from {} exceeded {} bytes with no line ending; closing \
337 decision=refused_body_too_large",
338 peer_addr, MAX_REQUEST_BYTES
339 ));
340 let _ = write_counted(
341 write_half,
342 &wire_failure_item("netget: selector line too long"),
343 app_state,
344 server_id,
345 connection_id,
346 )
347 .await;
348 return;
349 }
350 }
351 Err(e) => {
352 log.error(format!("Gopher read error from {}: {}", peer_addr, e));
353 return;
354 }
355 }
356 };
357
358 // Anything after the first line is not part of a Gopher request; there is no second
359 // request on this connection, so it is discarded with the socket.
360 let line = String::from_utf8_lossy(&request[..line_end])
361 .trim_end_matches('\r')
362 .to_string();
363
364 // A type-7 search request is `<selector>\t<query>`. Splitting on the first tab only:
365 // a query may itself contain tabs, and they belong to the query.
366 let (selector, search_query) = match line.split_once('\t') {
367 Some((sel, query)) => (sel.to_string(), Some(query.to_string())),
368 None => (line.clone(), None),
369 };
370
371 log.debug(format!(
372 "Gopher received {} bytes from {}",
373 request.len(),
374 peer_addr
375 ));
376 log.trace(format!("Gopher request line: {:?}", line));
377
378 let mut event_data = serde_json::json!({ "selector": selector });
379 if let Some(query) = &search_query {
380 // Present only for a type-7 request: its absence distinguishes "open the search
381 // form" from "search for the empty string".
382 event_data["search_query"] = serde_json::Value::String(query.clone());
383 }
384 let event = Event::new(&GOPHER_REQUEST_EVENT, event_data);
385
386 // --- Answer it ----------------------------------------------------------------------
387 match call_llm(
388 llm_client,
389 app_state,
390 server_id,
391 Some(connection_id),
392 &event,
393 protocol.as_ref(),
394 )
395 .await
396 {
397 Ok(execution_result) => {
398 for message in &execution_result.messages {
399 log.info(message);
400 }
401
402 let mut wrote_output = false;
403 let mut asked_to_close = false;
404 for protocol_result in execution_result.protocol_results {
405 match protocol_result {
406 crate::llm::actions::protocol_trait::ActionResult::Output(output_data) => {
407 if let Err(e) = write_counted(
408 write_half,
409 &output_data,
410 app_state,
411 server_id,
412 connection_id,
413 )
414 .await
415 {
416 log.error(format!("Gopher write error: {}", e));
417 return;
418 }
419 wrote_output = true;
420 log.debug(format!(
421 "Gopher sent {} bytes to {}",
422 output_data.len(),
423 peer_addr
424 ));
425 log.trace(format!(
426 "Gopher response: {}",
427 String::from_utf8_lossy(&output_data)
428 ));
429 log.info(format!(
430 "Gopher response to {} ({} bytes) decision=model_answer",
431 peer_addr,
432 output_data.len()
433 ));
434 }
435 crate::llm::actions::protocol_trait::ActionResult::CloseConnection => {
436 asked_to_close = true;
437 log.debug("Gopher close_connection requested");
438 }
439 _ => {}
440 }
441 }
442
443 // A `close_connection` and nothing else is the model choosing to hang up without
444 // saying anything — a real answer, and one the peer reads as an empty document. It
445 // is deliberately *not* a failure, so it never carries a `fail_closed_` token.
446 if asked_to_close && !wrote_output {
447 log.info(format!(
448 "Gopher closing on {} without a reply decision=model_reject",
449 peer_addr
450 ));
451 }
452
453 // Nothing reached the wire and nobody asked to hang up: every action failed, or
454 // the answer was empty. A client that reads to EOF would take that as an empty
455 // document, so say what happened in the one way Gopher has to say it.
456 if !wrote_output && !asked_to_close {
457 // The model answering with nothing and the model answering with something the
458 // executor refused are different faults with the same wire result, so the log
459 // is the only place they can be told apart.
460 let failures = execution_result.failures.len();
461 let msg = format!(
462 "Gopher produced no response for {} ({} failed action(s)); \
463 answering with a type-3 error item decision={}",
464 peer_addr,
465 failures,
466 if failures == 0 {
467 "model_silent"
468 } else {
469 "fail_closed_bad_action"
470 }
471 );
472 if failures == 0 {
473 log.warn(msg);
474 } else {
475 log.error(msg);
476 }
477 let _ = write_counted(
478 write_half,
479 &wire_failure_item(crate::utils::WireFailure::Unavailable.prefixed_text()),
480 app_state,
481 server_id,
482 connection_id,
483 )
484 .await;
485 }
486 }
487 Err(e) => {
488 // The category, never the error. `prefixed_wire_failure_text` returns `&'static str`
489 // precisely so nothing derived from `e` can reach the peer. Saturation and a dead
490 // backend produce the same type-3 item, so the decision token carries the
491 // distinction instead, and the error itself goes only to the log.
492 let token = match crate::utils::WireFailure::classify(&e) {
493 crate::utils::WireFailure::Overloaded => "fail_closed_llm_overloaded",
494 crate::utils::WireFailure::Unavailable => "fail_closed_llm_error",
495 };
496 log.error(format!(
497 "Gopher LLM call failed for {} decision={}: {}",
498 peer_addr, token, e
499 ));
500 let _ = write_counted(
501 write_half,
502 &wire_failure_item(crate::utils::prefixed_wire_failure_text(&e)),
503 app_state,
504 server_id,
505 connection_id,
506 )
507 .await;
508 }
509 }
510
511 // Return unconditionally: RFC 1436 closes after the reply, and the caller's shutdown is
512 // what tells the client the transfer is complete.
513 log.debug(format!(
514 "Gopher closing connection to {} after one reply (RFC 1436)",
515 peer_addr
516 ));
517}