Skip to main content

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}