Skip to main content

netget/server/ftp/
mod.rs

1//! FTP server implementation
2//!
3//! File Transfer Protocol (RFC 959) server with LLM-controlled responses.
4//! Supports basic FTP commands: USER, PASS, SYST, PWD, CWD, LIST, RETR, STOR, QUIT, etc.
5
6pub mod actions;
7
8use anyhow::Result;
9use std::net::SocketAddr;
10use std::sync::Arc;
11use std::time::Duration;
12use tokio::sync::mpsc;
13use tokio::sync::Mutex;
14
15use crate::llm::action_helper::call_llm;
16use crate::llm::ollama_client::OllamaClient;
17use crate::llm::ActionResult;
18use crate::logging::emit::Log;
19use crate::protocol::Event;
20use crate::server::ftp::actions::FtpProtocol;
21use crate::state::app_state::AppState;
22use actions::FTP_COMMAND_EVENT;
23
24/// FTP server that provides LLM-controlled file transfer operations
25pub struct FtpServer;
26
27impl FtpServer {
28    /// Spawn FTP server with integrated LLM actions
29    #[allow(clippy::too_many_arguments)]
30    pub async fn spawn_with_llm_actions(
31        listen_addr: SocketAddr,
32        llm_client: OllamaClient,
33        app_state: Arc<AppState>,
34        status_tx: mpsc::UnboundedSender<String>,
35        server_id: crate::state::ServerId,
36        first_byte_timeout_secs: Option<u64>,
37        idle_timeout_secs: Option<u64>,
38    ) -> Result<SocketAddr> {
39        // Both bounds are tunable because their right value is a property of who is on the
40        // other end, which only the operator knows. The defaults serve NetGet's own FTP client
41        // waiting on a human; a listener exposed to strangers wants the first one much lower.
42        let deadlines = ReadDeadlines::resolve(first_byte_timeout_secs, idle_timeout_secs);
43        let listener =
44            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
45        let local_addr = listener.local_addr()?;
46
47        Log::new(Some(&status_tx)).info(format!("FTP server listening on {}", local_addr));
48
49        let protocol = Arc::new(FtpProtocol::new());
50
51        let task_registrar = app_state.clone();
52        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
53        let accept_handle = tokio::spawn(async move {
54            loop {
55                match crate::server::accept_bounded::accept_bounded(
56                    &listener,
57                    &limiter,
58                    CONNECTION_CAP_REFUSAL,
59                    "FTP",
60                    Some(&status_tx),
61                )
62                .await
63                {
64                    Ok((stream, remote_addr, permit)) => {
65                        let connection_id = crate::server::connection::ConnectionId::new(
66                            app_state.get_next_unified_id().await,
67                        );
68                        Log::new(Some(&status_tx)).info(format!(
69                            "FTP connection {} from {}",
70                            connection_id, remote_addr
71                        ));
72
73                        let llm_clone = llm_client.clone();
74                        let state_clone = app_state.clone();
75                        let status_clone = status_tx.clone();
76                        let protocol_clone = protocol.clone();
77                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
78
79                        // Tracked, not detached: stop_server must abort this task too.
80                        let task_owner = app_state.clone();
81                        task_owner
82                            .spawn_server_task(server_id, async move {
83                                // Held for the life of the connection: releasing it here would
84                                // cap the accept rate rather than the number of live
85                                // connections.
86                                let _permit = permit;
87                                // Register the connection so it shows up in the TUI and in
88                                // list_connections, and so stop_server accounts for it.
89                                use crate::state::server::{
90                                    ConnectionState as ServerConnectionState, ConnectionStatus,
91                                    ProtocolConnectionInfo,
92                                };
93                                let now = crate::utils::clock::Instant::now();
94                                let conn_state = ServerConnectionState {
95                                    id: connection_id,
96                                    remote_addr,
97                                    local_addr: local_addr_conn,
98                                    bytes_sent: 0,
99                                    bytes_received: 0,
100                                    packets_sent: 0,
101                                    packets_received: 0,
102                                    last_activity: now,
103                                    status: ConnectionStatus::Active,
104                                    status_changed_at: now,
105                                    protocol_info: ProtocolConnectionInfo::empty(),
106                                };
107                                state_clone
108                                    .add_connection_to_server(server_id, conn_state)
109                                    .await;
110                                let _ = status_clone.send("__UPDATE_UI__".to_string());
111
112                                if let Err(e) = FtpSession::handle_session(
113                                    stream,
114                                    connection_id,
115                                    server_id,
116                                    llm_clone,
117                                    state_clone.clone(),
118                                    status_clone.clone(),
119                                    protocol_clone,
120                                    deadlines,
121                                )
122                                .await
123                                {
124                                    Log::new(Some(&status_clone))
125                                        .error(format!("FTP session error: {}", e));
126                                }
127
128                                state_clone
129                                    .close_connection_on_server(server_id, connection_id)
130                                    .await;
131                                let _ = status_clone.send("__UPDATE_UI__".to_string());
132                            })
133                            .await;
134                    }
135                    Err(e) => {
136                        Log::new(Some(&status_tx))
137                            .error(format!("Failed to accept FTP connection: {}", e));
138                        break;
139                    }
140                }
141            }
142        });
143
144        task_registrar
145            .register_server_task(server_id, accept_handle)
146            .await;
147
148        Ok(local_addr)
149    }
150}
151
152/// How long to wait for the first command after the `220` greeting has been sent.
153///
154/// **This was 60 seconds, and "FTP is server-speaks-first" was the wrong reason for it.** The
155/// argument read: every real client — `ftp(1)`, `lftp`, curl, a browser — answers the greeting
156/// with `USER` from inside its own connect path, with no human in the loop yet, so a minute is
157/// far more than that needs. Every word of that is true and none of it is about the peer this
158/// server most often has. The greeting is *ours*; sending it says nothing about whether the
159/// other end will answer it.
160///
161/// The question a first-byte bound actually has to answer is whether NetGet's own client of
162/// this protocol can be connected and silent. FTP's can, and is, by default:
163/// `src/client/ftp/mod.rs` opens the socket, splits it, registers the command channel, raises
164/// `ftp_connected`, and then reads the `220` in its read loop. It writes nothing of its own —
165/// every byte it puts on the wire comes from an action, and a client created from the
166/// dashboard's `[ + ftp client ]` is routed `ftp_connected` → static-with-no-actions and then
167/// `*` → manual (`src/tui/modal/form.rs`). So it connects, is answered with nothing, reads our
168/// greeting, parks that too, and waits for a person to type into `[ send message ]`. At 60
169/// seconds this server hung up on the operator's own client while they were still looking at
170/// it.
171///
172/// 300 seconds is the window a `manual` rule gives a human to answer one event
173/// (`src/state/intercepts.rs`), which is the number this product already uses for how long
174/// someone might take.
175///
176/// What it costs: a stranger holding a socket, a task, an `AppState` row and one of
177/// [`MAX_CONNECTIONS`] slots while saying nothing now gets 300 seconds rather than 60. That is
178/// a fivefold rise in how long one idle slot is held, not a removal of the bound — the cap
179/// still holds and a peer over it is still answered [`CONNECTION_CAP_REFUSAL`]. A listener
180/// genuinely exposed to strangers should set `first_byte_timeout_secs` low; 60 is the old
181/// value and remains a sound choice for one.
182///
183/// The greeting itself is generated and written before the command loop begins, so the model's
184/// time over it, and a `manual` rule parking it for a human, are outside this deadline by
185/// construction — as is every later LLM round-trip, which happens after a line has been read.
186const FIRST_COMMAND_READ_TIMEOUT: Duration = Duration::from_secs(300);
187
188/// The two read deadlines for one connection, resolved from the server's startup parameters.
189///
190/// Carried as one value so threading them from `spawn_with_llm_actions` down to the command
191/// loop costs one argument rather than two at each hop.
192#[derive(Clone, Copy)]
193struct ReadDeadlines {
194    /// [`FIRST_COMMAND_READ_TIMEOUT`], or this server's `first_byte_timeout_secs`.
195    first_byte: Duration,
196    /// [`IDLE_BETWEEN_COMMANDS_TIMEOUT`], or this server's `idle_timeout_secs`.
197    idle: Duration,
198}
199
200impl ReadDeadlines {
201    fn resolve(first_byte_secs: Option<u64>, idle_secs: Option<u64>) -> Self {
202        Self {
203            first_byte: first_byte_secs
204                .map(Duration::from_secs)
205                .unwrap_or(FIRST_COMMAND_READ_TIMEOUT),
206            idle: idle_secs
207                .map(Duration::from_secs)
208                .unwrap_or(IDLE_BETWEEN_COMMANDS_TIMEOUT),
209        }
210    }
211}
212
213/// How long to wait for a *further* command once one has been answered.
214///
215/// Five minutes, which is vsftpd's `idle_session_timeout` default — the idle bound on the FTP
216/// control connection that every client in use is already built to tolerate, and which
217/// ProFTPD's `TimeoutIdle` only doubles. It has to be on a human timescale rather than a
218/// machine one: `ftp(1)` prompts the person at it for the password after `USER`, and again for
219/// each command of an interactive session, so the silence between two commands here is someone
220/// typing.
221///
222/// The LLM round-trip and a `manual` rule parking a command for a human
223/// (`src/state/intercepts.rs`, 300s by default) both happen after a line has already been read,
224/// so neither can be timed out from under itself.
225///
226/// Overridable per server with `idle_timeout_secs`, for the same reason the first bound is:
227/// the right value is a property of who is on the other end, and only the operator knows that.
228const IDLE_BETWEEN_COMMANDS_TIMEOUT: Duration = Duration::from_secs(300);
229
230/// Concurrent control connections this server admits.
231///
232/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. Each connection may buffer up to
233/// [`MAX_COMMAND_LINE`], so this is the multiplier that turns that per-connection bound into a
234/// total one.
235const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
236
237/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
238///
239/// `421 Service not available, closing control connection` is RFC 959's own reply for exactly
240/// this — the server declining to open a session — and it is what real FTP servers send when
241/// they are at their client limit. A `4xx` is a transient negative reply, so a client retries
242/// later rather than recording a permanent failure. Fixed text, so there is no placeholder an
243/// internal error could reach.
244const CONNECTION_CAP_REFUSAL: &[u8] = b"421 Too many connections, closing control connection\r\n";
245
246/// Largest control line the server will accumulate before giving up on the peer.
247///
248/// RFC 959 commands are short — the longest standard one is a `STOR` with a path — and
249/// 8 KiB is far above anything a real client sends. The cap exists because
250/// [`tokio::io::AsyncBufReadExt::read_line`] grows its buffer until it finds a newline: a
251/// peer that opens a connection and streams bytes with no `\n` makes the server allocate
252/// without bound, which is a one-connection out-of-memory.
253pub const MAX_COMMAND_LINE: usize = 8192;
254
255/// The result of trying to read one control line.
256enum CommandLine {
257    /// A complete line. The trailing CRLF is still attached; the caller trims it.
258    Line(String),
259    /// The peer closed the control connection.
260    Eof,
261    /// The peer sent `MAX_COMMAND_LINE` bytes with no newline in them.
262    TooLong,
263}
264
265/// Read a single CRLF-terminated FTP command, refusing to buffer more than `max_len` bytes.
266///
267/// Returns the line and the number of bytes consumed from the socket, so the caller can
268/// account for them in the connection stats exactly as `read_line` allowed.
269async fn read_command_line<R>(
270    reader: &mut tokio::io::BufReader<R>,
271    max_len: usize,
272) -> std::io::Result<(CommandLine, usize)>
273where
274    R: tokio::io::AsyncRead + Unpin,
275{
276    use tokio::io::AsyncBufReadExt;
277
278    let mut buf: Vec<u8> = Vec::new();
279    loop {
280        // `fill_buf` borrows the reader, so decide what to take and drop the borrow before
281        // calling `consume`.
282        let (newline_at, available_len) = {
283            let available = reader.fill_buf().await?;
284            (available.iter().position(|&b| b == b'\n'), available.len())
285        };
286
287        if available_len == 0 {
288            // EOF. A trailing fragment with no newline is not a command; the peer hung up
289            // mid-line, which is the same as hanging up.
290            return Ok((CommandLine::Eof, buf.len()));
291        }
292
293        let take = match newline_at {
294            Some(idx) => idx + 1,
295            None => available_len,
296        };
297
298        if buf.len() + take > max_len {
299            // Consume what we looked at so the reader is not left mid-buffer, then give up:
300            // the caller closes the connection, so there is nothing to resynchronise with.
301            reader.consume(take);
302            return Ok((CommandLine::TooLong, buf.len() + take));
303        }
304
305        {
306            let available = reader.fill_buf().await?;
307            buf.extend_from_slice(&available[..take]);
308        }
309        reader.consume(take);
310
311        if newline_at.is_some() {
312            let consumed = buf.len();
313            // `from_utf8_lossy` rather than a hard error: a stray non-UTF-8 byte in a
314            // command is the peer's problem to be answered with 500, not a reason to drop
315            // the connection without a reply.
316            return Ok((
317                CommandLine::Line(String::from_utf8_lossy(&buf).into_owned()),
318                consumed,
319            ));
320        }
321    }
322}
323
324/// The RFC 959 reply code at the head of a control-connection write, when there is one.
325///
326/// For the log only. FTP has a rich enough vocabulary that the *code* is the outcome, so the
327/// `decision=` line names what the peer actually received — which is how an operator sees
328/// that a PASS was answered 530 and not 230.
329fn reply_code_of(data: &[u8]) -> Option<u16> {
330    let head = data.get(..3)?;
331    if head.iter().all(u8::is_ascii_digit) {
332        std::str::from_utf8(head).ok()?.parse().ok()
333    } else {
334        None
335    }
336}
337
338/// Tracks the one-reply-per-command rule across an answer's `Output`s.
339///
340/// RFC 959 gives each command exactly one completion reply (2xx-5xx), optionally preceded by
341/// preliminary 1xx replies. The real-model eval saw llama3.1:8b answer a connection with five
342/// `220`s: the client took the first as the greeting and the next four as the answers to the
343/// commands it sent afterwards, so `USER anonymous` read `220 FTP Server Ready` and no login
344/// ever happened (`ftp/anonymous-login` 0/5). Everything after the first completion reply is
345/// dropped and counted, and the caller logs `decision=duplicate_response_dropped`.
346#[derive(Default)]
347struct OneReply {
348    completed: bool,
349    dropped: usize,
350}
351
352impl OneReply {
353    /// Whether `data` may go on the wire.
354    fn admit(&mut self, data: &[u8]) -> bool {
355        if self.completed {
356            self.dropped += 1;
357            return false;
358        }
359        // A non-numeric write (the model's free text) is not a reply code; let it through and
360        // leave the rule to the numbered replies around it.
361        if reply_code_of(data).is_some_and(|code| code >= 200) {
362            self.completed = true;
363        }
364        true
365    }
366
367    fn log_drops(&self, log: &Log, connection_id: impl std::fmt::Display, what: &str) {
368        if self.dropped > 0 {
369            log.warn(format!(
370                "FTP {what} on connection {connection_id} decision=duplicate_response_dropped: \
371                 {} further reply(ies) after the completion reply were not sent",
372                self.dropped
373            ));
374        }
375    }
376}
377
378/// How a single `ftp_command` event ended, as a stable `decision=` token plus the detail the
379/// log line carries.
380///
381/// Computed **before** `protocol_results` is consumed, because the command loop moves them
382/// into a `for` and the `CloseConnection` arm returns straight out of it.
383///
384/// The three non-answer outcomes are the point. A model that refuses (`close_connection`), a
385/// model that answered with nothing, and a model whose action the executor refused all leave
386/// the *same* thing on the wire today — nothing at all — so the log is the only place they
387/// can be told apart, and only the first of the three is a decision anybody made.
388fn classify_ftp_outcome(result: &crate::llm::ExecutionResult) -> (&'static str, String) {
389    let codes: Vec<String> = result
390        .protocol_results
391        .iter()
392        .flat_map(|r| r.get_all_output())
393        .map(|d| match reply_code_of(&d) {
394            Some(code) => code.to_string(),
395            None => "non-numeric".to_string(),
396        })
397        .collect();
398
399    if !codes.is_empty() {
400        return ("model_answer", format!("reply {}", codes.join(",")));
401    }
402
403    // Only a top-level `CloseConnection` is honoured by the command loop, so only a top-level
404    // one is reported here.
405    if result
406        .protocol_results
407        .iter()
408        .any(|r| matches!(r, ActionResult::CloseConnection))
409    {
410        return (
411            "model_reject",
412            "close_connection: control connection closed with no reply".to_string(),
413        );
414    }
415
416    // `wait_for_more` also writes nothing, but it is a decision the model made and declared,
417    // not an absence of one. Folding it into `model_silent` would be the exact conflation this
418    // tagging exists to prevent.
419    if result
420        .protocol_results
421        .iter()
422        .any(|r| matches!(r, ActionResult::WaitForMore))
423    {
424        return (
425            "model_wait_for_more",
426            "wait_for_more: no reply until the client sends another line".to_string(),
427        );
428    }
429
430    if !result.failures.is_empty() {
431        let detail = result
432            .failures
433            .iter()
434            .map(|f| format!("{}: {}", f.action, f.error))
435            .collect::<Vec<_>>()
436            .join("; ");
437        return ("fail_closed_bad_action", detail);
438    }
439
440    (
441        "model_silent",
442        "no usable action; nothing was written and the client is left waiting".to_string(),
443    )
444}
445
446struct FtpSession;
447
448impl FtpSession {
449    /// Handle an FTP session
450    ///
451    /// Owns the split socket. The write half lives in an `Arc<Mutex<_>>` shared with the
452    /// peer command task, so the dashboard's "message this peer" / "disconnect this peer"
453    /// write through the same half the session does. The peer handle is registered here
454    /// (the accept loop has already added the connection) and removed on every exit —
455    /// EOF, 421, `close_connection` and errors all funnel through the single return below.
456    #[allow(clippy::too_many_arguments)]
457    async fn handle_session(
458        stream: tokio::net::TcpStream,
459        connection_id: crate::server::connection::ConnectionId,
460        server_id: crate::state::ServerId,
461        llm_client: OllamaClient,
462        app_state: Arc<AppState>,
463        status_tx: mpsc::UnboundedSender<String>,
464        protocol: Arc<FtpProtocol>,
465        deadlines: ReadDeadlines,
466    ) -> Result<()> {
467        let (read_half, write_half) = tokio::io::split(stream);
468        let write_half = Arc::new(Mutex::new(write_half));
469
470        let peer_rx = crate::server::peer_support::register_peer_channel(
471            &app_state,
472            server_id,
473            connection_id.as_u32(),
474        )
475        .await;
476        crate::server::peer_support::spawn_peer_command_task(
477            peer_rx,
478            protocol.clone(),
479            app_state.clone(),
480            server_id,
481            connection_id.as_u32(),
482            write_half.clone(),
483            status_tx.clone(),
484        );
485
486        let result = async {
487            Self::send_greeting(
488                &write_half,
489                connection_id,
490                server_id,
491                &llm_client,
492                &app_state,
493                &status_tx,
494                &protocol,
495            )
496            .await?;
497
498            Self::handle_session_commands(
499                read_half,
500                &write_half,
501                connection_id,
502                server_id,
503                llm_client,
504                &app_state,
505                &status_tx,
506                protocol,
507                deadlines,
508            )
509            .await
510        }
511        .await;
512
513        app_state
514            .remove_peer_handle(server_id, connection_id.as_u32())
515            .await;
516        result
517    }
518
519    /// Lock the shared write half, write + flush, and account for the bytes. The guard is
520    /// dropped before returning so no `.await` on the LLM ever holds it.
521    async fn write_out<W>(
522        write_half: &Arc<Mutex<W>>,
523        data: &[u8],
524        app_state: &AppState,
525        server_id: crate::state::ServerId,
526        connection_id: crate::server::connection::ConnectionId,
527    ) -> std::io::Result<()>
528    where
529        W: tokio::io::AsyncWrite + Unpin,
530    {
531        use tokio::io::AsyncWriteExt;
532        {
533            let mut write = write_half.lock().await;
534            write.write_all(data).await?;
535            write.flush().await?;
536        }
537        app_state
538            .update_connection_stats(
539                server_id,
540                connection_id,
541                None,
542                Some(data.len() as u64),
543                None,
544                Some(1),
545            )
546            .await;
547        Ok(())
548    }
549
550    /// Send FTP greeting (220 response)
551    async fn send_greeting<W>(
552        write_half: &Arc<Mutex<W>>,
553        connection_id: crate::server::connection::ConnectionId,
554        server_id: crate::state::ServerId,
555        llm_client: &OllamaClient,
556        app_state: &Arc<AppState>,
557        status_tx: &mpsc::UnboundedSender<String>,
558        protocol: &Arc<FtpProtocol>,
559    ) -> Result<()>
560    where
561        W: tokio::io::AsyncWrite + Unpin,
562    {
563        // The client will not speak until it has seen a 2xx greeting, so this event is the
564        // handler's only chance to produce one. `CONNECTION_ESTABLISHED` is a sentinel, not a
565        // command the client sent - see FTP_COMMAND_EVENT.
566        let greeting_event = Event::new(
567            &FTP_COMMAND_EVENT,
568            serde_json::json!({
569                "command": "CONNECTION_ESTABLISHED",
570                "answer_with": actions::answer_with_for_command("CONNECTION_ESTABLISHED"),
571            }),
572        );
573
574        match call_llm(
575            llm_client,
576            app_state,
577            server_id,
578            Some(connection_id),
579            &greeting_event,
580            protocol.as_ref(),
581        )
582        .await
583        {
584            Ok(execution_result) => {
585                let (decision, detail) = classify_ftp_outcome(&execution_result);
586                let log = Log::new(Some(status_tx));
587                match decision {
588                    "model_answer" => log.info(format!(
589                        "FTP greeting on connection {connection_id} decision=model_answer \
590                         ({detail})"
591                    )),
592                    "model_reject" => log.info(format!(
593                        "FTP greeting on connection {connection_id} decision=model_reject \
594                         ({detail})"
595                    )),
596                    "model_wait_for_more" => log.info(format!(
597                        "FTP greeting on connection {connection_id} \
598                         decision=model_wait_for_more ({detail})"
599                    )),
600                    "fail_closed_bad_action" => log.error(format!(
601                        "FTP greeting on connection {connection_id} \
602                         decision=fail_closed_bad_action ({detail}); the client is waiting for a \
603                         220 that will never arrive"
604                    )),
605                    _ => log.warn(format!(
606                        "FTP greeting on connection {connection_id} decision=model_silent \
607                         ({detail}); an FTP client may send no command until it has read a \
608                         greeting"
609                    )),
610                }
611
612                let mut one_reply = OneReply::default();
613                for protocol_result in execution_result.protocol_results {
614                    if let ActionResult::Output(data) = protocol_result {
615                        if !one_reply.admit(&data) {
616                            continue;
617                        }
618                        Self::write_out(write_half, &data, app_state, server_id, connection_id)
619                            .await?;
620                    }
621                }
622                one_reply.log_drops(&log, connection_id, "greeting");
623            }
624            Err(e) => {
625                // Logging it was an improvement on silence for *us*; the client still sat
626                // waiting for a greeting that never came, because an FTP client may not send
627                // a command until it has read one. RFC 959 defines 421 as the greeting a
628                // server sends when it is declining the session, and it closes afterwards -
629                // which is the same shape SMTP uses, for the same reason.
630                let failure = crate::utils::WireFailure::classify(&e);
631                let notice = failure.prefixed_text();
632                let decision = if failure.is_overloaded() {
633                    "fail_closed_llm_overloaded"
634                } else {
635                    "fail_closed_llm_error"
636                };
637                Log::new(Some(status_tx)).error(format!(
638                    "FTP greeting on connection {connection_id} decision={decision}, refused \
639                     with 421 ({notice}): {e}"
640                ));
641                let reply =
642                    format!("421 Service not available, closing control connection ({notice})\r\n");
643                let _ = Self::write_out(
644                    write_half,
645                    reply.as_bytes(),
646                    app_state,
647                    server_id,
648                    connection_id,
649                )
650                .await;
651                // 421 means the control connection is closing, so the session must not
652                // continue into the command loop. The caller propagates this with `?`, which
653                // ends the connection task and drops the socket.
654                return Err(anyhow::anyhow!("FTP greeting refused with 421: {e}"));
655            }
656        }
657
658        Ok(())
659    }
660
661    /// Handle FTP session commands
662    #[allow(clippy::too_many_arguments)]
663    async fn handle_session_commands<R, W>(
664        read_half: R,
665        write_half: &Arc<Mutex<W>>,
666        connection_id: crate::server::connection::ConnectionId,
667        server_id: crate::state::ServerId,
668        llm_client: OllamaClient,
669        app_state: &Arc<AppState>,
670        status_tx: &mpsc::UnboundedSender<String>,
671        protocol: Arc<FtpProtocol>,
672        deadlines: ReadDeadlines,
673    ) -> Result<()>
674    where
675        R: tokio::io::AsyncRead + Unpin,
676        W: tokio::io::AsyncWrite + Unpin,
677    {
678        use tokio::io::BufReader;
679
680        let mut reader = BufReader::new(read_half);
681        let log = Log::new(Some(status_tx));
682
683        let mut answered_one = false;
684        loop {
685            // The deadline wraps this read and nothing else. Everything that can legitimately
686            // take minutes — the LLM round-trip, and a `manual` rule parking the command for a
687            // human to answer — happens below, after a line has already been read.
688            let read_timeout = if answered_one {
689                deadlines.idle
690            } else {
691                deadlines.first_byte
692            };
693            let (outcome, n) = match tokio::time::timeout(
694                read_timeout,
695                read_command_line(&mut reader, MAX_COMMAND_LINE),
696            )
697            .await
698            {
699                Ok(result) => result?,
700                Err(_) => {
701                    log.info(format!(
702                        "FTP connection {connection_id} sent nothing for {}s; closing idle \
703                         control connection",
704                        read_timeout.as_secs()
705                    ));
706                    return Ok(());
707                }
708            };
709            answered_one = true;
710            let line = match outcome {
711                CommandLine::Eof => break,
712                CommandLine::TooLong => {
713                    // A control line this long is not an FTP command. RFC 959 has no
714                    // "line too long" code, but 500 is the syntax-error reply and the
715                    // connection is closed so the peer cannot keep feeding the buffer.
716                    log.warn(format!(
717                        "FTP command line from connection {connection_id} \
718                         decision=refused_line_too_long: exceeded {MAX_COMMAND_LINE} bytes \
719                         without a newline; answered 500 and closing"
720                    ));
721                    let _ = Self::write_out(
722                        write_half,
723                        b"500 Command line too long\r\n",
724                        app_state,
725                        server_id,
726                        connection_id,
727                    )
728                    .await;
729                    return Ok(());
730                }
731                CommandLine::Line(line) => line,
732            };
733            app_state
734                .update_connection_stats(
735                    server_id,
736                    connection_id,
737                    Some(n as u64),
738                    None,
739                    Some(1),
740                    None,
741                )
742                .await;
743
744            let command = line.trim();
745            // FileOnly: the ftp_command event template surfaces the command on the TUI.
746            log.debug(format!("FTP received: {}", command));
747
748            // Create FTP command event
749            let event = Event::new(&FTP_COMMAND_EVENT, {
750                let mut data = serde_json::json!({ "command": command });
751                if let Some(hint) = actions::answer_with_for_command(&command) {
752                    data["answer_with"] = serde_json::json!(hint);
753                }
754                data
755            });
756
757            // Get handler/LLM response
758            match call_llm(
759                &llm_client,
760                app_state,
761                server_id,
762                Some(connection_id),
763                &event,
764                protocol.as_ref(),
765            )
766            .await
767            {
768                Ok(execution_result) => {
769                    let (decision, detail) = classify_ftp_outcome(&execution_result);
770                    match decision {
771                        "model_answer" => log.info(format!(
772                            "FTP {command:?} on connection {connection_id} \
773                             decision=model_answer ({detail})"
774                        )),
775                        "model_reject" => log.info(format!(
776                            "FTP {command:?} on connection {connection_id} \
777                             decision=model_reject ({detail})"
778                        )),
779                        "model_wait_for_more" => log.info(format!(
780                            "FTP {command:?} on connection {connection_id} \
781                             decision=model_wait_for_more ({detail})"
782                        )),
783                        "fail_closed_bad_action" => log.error(format!(
784                            "FTP {command:?} on connection {connection_id} \
785                             decision=fail_closed_bad_action ({detail}); nothing was written and \
786                             the client is left waiting"
787                        )),
788                        _ => log.warn(format!(
789                            "FTP {command:?} on connection {connection_id} \
790                             decision=model_silent ({detail})"
791                        )),
792                    }
793
794                    let mut one_reply = OneReply::default();
795                    for protocol_result in execution_result.protocol_results {
796                        match protocol_result {
797                            ActionResult::Output(data) => {
798                                if !one_reply.admit(&data) {
799                                    continue;
800                                }
801                                Self::write_out(
802                                    write_half,
803                                    &data,
804                                    app_state,
805                                    server_id,
806                                    connection_id,
807                                )
808                                .await?;
809
810                                let response = String::from_utf8_lossy(&data);
811                                log.debug(format!("FTP sent: {}", response.trim()));
812                            }
813                            ActionResult::CloseConnection => {
814                                one_reply.log_drops(&log, connection_id, "reply");
815                                return Ok(());
816                            }
817                            _ => {}
818                        }
819                    }
820                    one_reply.log_drops(&log, connection_id, "reply");
821                }
822                Err(e) => {
823                    // Do not leave the client hanging with no diagnostic: RFC 959 421 tells it
824                    // the service is unavailable and the control connection is closing.
825                    //
826                    // The peer gets the `WireFailure` *category* — the same one the greeting
827                    // path uses — never the error text. `prefixed_text()` returns
828                    // `&'static str`, so nothing derived from `e` can reach the wire; `e`
829                    // itself goes to the log, which is where an operator looks.
830                    let failure = crate::utils::WireFailure::classify(&e);
831                    let notice = failure.prefixed_text();
832                    let decision = if failure.is_overloaded() {
833                        "fail_closed_llm_overloaded"
834                    } else {
835                        "fail_closed_llm_error"
836                    };
837                    log.error(format!(
838                        "FTP {command:?} on connection {connection_id} decision={decision}, \
839                         refused with 421 ({notice}): {e}"
840                    ));
841                    let reply = format!(
842                        "421 Service not available, closing control connection ({notice})\r\n"
843                    );
844                    let _ = Self::write_out(
845                        write_half,
846                        reply.as_bytes(),
847                        app_state,
848                        server_id,
849                        connection_id,
850                    )
851                    .await;
852                    return Ok(());
853                }
854            }
855        }
856
857        Ok(())
858    }
859}
860
861// Stub types needed for non-feature compilation