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