pub mod actions;
use anyhow::Result;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::sync::Mutex;
use crate::llm::action_helper::call_llm;
use crate::llm::ollama_client::OllamaClient;
use crate::llm::ActionResult;
use crate::logging::emit::Log;
use crate::protocol::Event;
use crate::server::ftp::actions::FtpProtocol;
use crate::state::app_state::AppState;
use actions::FTP_COMMAND_EVENT;
pub struct FtpServer;
impl FtpServer {
#[allow(clippy::too_many_arguments)]
pub async fn spawn_with_llm_actions(
listen_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
first_byte_timeout_secs: Option<u64>,
idle_timeout_secs: Option<u64>,
) -> Result<SocketAddr> {
let deadlines = ReadDeadlines::resolve(first_byte_timeout_secs, idle_timeout_secs);
let listener =
crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
let local_addr = listener.local_addr()?;
Log::new(Some(&status_tx)).info(format!("FTP server listening on {}", local_addr));
let protocol = Arc::new(FtpProtocol::new());
let task_registrar = app_state.clone();
let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
let accept_handle = tokio::spawn(async move {
loop {
match crate::server::accept_bounded::accept_bounded(
&listener,
&limiter,
CONNECTION_CAP_REFUSAL,
"FTP",
Some(&status_tx),
)
.await
{
Ok((stream, remote_addr, permit)) => {
let connection_id = crate::server::connection::ConnectionId::new(
app_state.get_next_unified_id().await,
);
Log::new(Some(&status_tx)).info(format!(
"FTP connection {} from {}",
connection_id, remote_addr
));
let llm_clone = llm_client.clone();
let state_clone = app_state.clone();
let status_clone = status_tx.clone();
let protocol_clone = protocol.clone();
let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id, async move {
let _permit = permit;
use crate::state::server::{
ConnectionState as ServerConnectionState, ConnectionStatus,
ProtocolConnectionInfo,
};
let now = crate::utils::clock::Instant::now();
let conn_state = ServerConnectionState {
id: connection_id,
remote_addr,
local_addr: local_addr_conn,
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
packets_received: 0,
last_activity: now,
status: ConnectionStatus::Active,
status_changed_at: now,
protocol_info: ProtocolConnectionInfo::empty(),
};
state_clone
.add_connection_to_server(server_id, conn_state)
.await;
let _ = status_clone.send("__UPDATE_UI__".to_string());
if let Err(e) = FtpSession::handle_session(
stream,
connection_id,
server_id,
llm_clone,
state_clone.clone(),
status_clone.clone(),
protocol_clone,
deadlines,
)
.await
{
Log::new(Some(&status_clone))
.error(format!("FTP session error: {}", e));
}
state_clone
.close_connection_on_server(server_id, connection_id)
.await;
let _ = status_clone.send("__UPDATE_UI__".to_string());
})
.await;
}
Err(e) => {
Log::new(Some(&status_tx))
.error(format!("Failed to accept FTP connection: {}", e));
break;
}
}
}
});
task_registrar
.register_server_task(server_id, accept_handle)
.await;
Ok(local_addr)
}
}
const FIRST_COMMAND_READ_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Clone, Copy)]
struct ReadDeadlines {
first_byte: Duration,
idle: Duration,
}
impl ReadDeadlines {
fn resolve(first_byte_secs: Option<u64>, idle_secs: Option<u64>) -> Self {
Self {
first_byte: first_byte_secs
.map(Duration::from_secs)
.unwrap_or(FIRST_COMMAND_READ_TIMEOUT),
idle: idle_secs
.map(Duration::from_secs)
.unwrap_or(IDLE_BETWEEN_COMMANDS_TIMEOUT),
}
}
}
const IDLE_BETWEEN_COMMANDS_TIMEOUT: Duration = Duration::from_secs(300);
const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
const CONNECTION_CAP_REFUSAL: &[u8] = b"421 Too many connections, closing control connection\r\n";
pub const MAX_COMMAND_LINE: usize = 8192;
enum CommandLine {
Line(String),
Eof,
TooLong,
}
async fn read_command_line<R>(
reader: &mut tokio::io::BufReader<R>,
max_len: usize,
) -> std::io::Result<(CommandLine, usize)>
where
R: tokio::io::AsyncRead + Unpin,
{
use tokio::io::AsyncBufReadExt;
let mut buf: Vec<u8> = Vec::new();
loop {
let (newline_at, available_len) = {
let available = reader.fill_buf().await?;
(available.iter().position(|&b| b == b'\n'), available.len())
};
if available_len == 0 {
return Ok((CommandLine::Eof, buf.len()));
}
let take = match newline_at {
Some(idx) => idx + 1,
None => available_len,
};
if buf.len() + take > max_len {
reader.consume(take);
return Ok((CommandLine::TooLong, buf.len() + take));
}
{
let available = reader.fill_buf().await?;
buf.extend_from_slice(&available[..take]);
}
reader.consume(take);
if newline_at.is_some() {
let consumed = buf.len();
return Ok((
CommandLine::Line(String::from_utf8_lossy(&buf).into_owned()),
consumed,
));
}
}
}
fn reply_code_of(data: &[u8]) -> Option<u16> {
let head = data.get(..3)?;
if head.iter().all(u8::is_ascii_digit) {
std::str::from_utf8(head).ok()?.parse().ok()
} else {
None
}
}
#[derive(Default)]
struct OneReply {
completed: bool,
dropped: usize,
}
impl OneReply {
fn admit(&mut self, data: &[u8]) -> bool {
if self.completed {
self.dropped += 1;
return false;
}
if reply_code_of(data).is_some_and(|code| code >= 200) {
self.completed = true;
}
true
}
fn log_drops(&self, log: &Log, connection_id: impl std::fmt::Display, what: &str) {
if self.dropped > 0 {
log.warn(format!(
"FTP {what} on connection {connection_id} decision=duplicate_response_dropped: \
{} further reply(ies) after the completion reply were not sent",
self.dropped
));
}
}
}
fn classify_ftp_outcome(result: &crate::llm::ExecutionResult) -> (&'static str, String) {
let codes: Vec<String> = result
.protocol_results
.iter()
.flat_map(|r| r.get_all_output())
.map(|d| match reply_code_of(&d) {
Some(code) => code.to_string(),
None => "non-numeric".to_string(),
})
.collect();
if !codes.is_empty() {
return ("model_answer", format!("reply {}", codes.join(",")));
}
if result
.protocol_results
.iter()
.any(|r| matches!(r, ActionResult::CloseConnection))
{
return (
"model_reject",
"close_connection: control connection closed with no reply".to_string(),
);
}
if result
.protocol_results
.iter()
.any(|r| matches!(r, ActionResult::WaitForMore))
{
return (
"model_wait_for_more",
"wait_for_more: no reply until the client sends another line".to_string(),
);
}
if !result.failures.is_empty() {
let detail = result
.failures
.iter()
.map(|f| format!("{}: {}", f.action, f.error))
.collect::<Vec<_>>()
.join("; ");
return ("fail_closed_bad_action", detail);
}
(
"model_silent",
"no usable action; nothing was written and the client is left waiting".to_string(),
)
}
struct FtpSession;
impl FtpSession {
#[allow(clippy::too_many_arguments)]
async fn handle_session(
stream: tokio::net::TcpStream,
connection_id: crate::server::connection::ConnectionId,
server_id: crate::state::ServerId,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
protocol: Arc<FtpProtocol>,
deadlines: ReadDeadlines,
) -> Result<()> {
let (read_half, write_half) = tokio::io::split(stream);
let write_half = Arc::new(Mutex::new(write_half));
let peer_rx = crate::server::peer_support::register_peer_channel(
&app_state,
server_id,
connection_id.as_u32(),
)
.await;
crate::server::peer_support::spawn_peer_command_task(
peer_rx,
protocol.clone(),
app_state.clone(),
server_id,
connection_id.as_u32(),
write_half.clone(),
status_tx.clone(),
);
let result = async {
Self::send_greeting(
&write_half,
connection_id,
server_id,
&llm_client,
&app_state,
&status_tx,
&protocol,
)
.await?;
Self::handle_session_commands(
read_half,
&write_half,
connection_id,
server_id,
llm_client,
&app_state,
&status_tx,
protocol,
deadlines,
)
.await
}
.await;
app_state
.remove_peer_handle(server_id, connection_id.as_u32())
.await;
result
}
async fn write_out<W>(
write_half: &Arc<Mutex<W>>,
data: &[u8],
app_state: &AppState,
server_id: crate::state::ServerId,
connection_id: crate::server::connection::ConnectionId,
) -> std::io::Result<()>
where
W: tokio::io::AsyncWrite + Unpin,
{
use tokio::io::AsyncWriteExt;
{
let mut write = write_half.lock().await;
write.write_all(data).await?;
write.flush().await?;
}
app_state
.update_connection_stats(
server_id,
connection_id,
None,
Some(data.len() as u64),
None,
Some(1),
)
.await;
Ok(())
}
async fn send_greeting<W>(
write_half: &Arc<Mutex<W>>,
connection_id: crate::server::connection::ConnectionId,
server_id: crate::state::ServerId,
llm_client: &OllamaClient,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
protocol: &Arc<FtpProtocol>,
) -> Result<()>
where
W: tokio::io::AsyncWrite + Unpin,
{
let greeting_event = Event::new(
&FTP_COMMAND_EVENT,
serde_json::json!({
"command": "CONNECTION_ESTABLISHED",
"answer_with": actions::answer_with_for_command("CONNECTION_ESTABLISHED"),
}),
);
match call_llm(
llm_client,
app_state,
server_id,
Some(connection_id),
&greeting_event,
protocol.as_ref(),
)
.await
{
Ok(execution_result) => {
let (decision, detail) = classify_ftp_outcome(&execution_result);
let log = Log::new(Some(status_tx));
match decision {
"model_answer" => log.info(format!(
"FTP greeting on connection {connection_id} decision=model_answer \
({detail})"
)),
"model_reject" => log.info(format!(
"FTP greeting on connection {connection_id} decision=model_reject \
({detail})"
)),
"model_wait_for_more" => log.info(format!(
"FTP greeting on connection {connection_id} \
decision=model_wait_for_more ({detail})"
)),
"fail_closed_bad_action" => log.error(format!(
"FTP greeting on connection {connection_id} \
decision=fail_closed_bad_action ({detail}); the client is waiting for a \
220 that will never arrive"
)),
_ => log.warn(format!(
"FTP greeting on connection {connection_id} decision=model_silent \
({detail}); an FTP client may send no command until it has read a \
greeting"
)),
}
let mut one_reply = OneReply::default();
for protocol_result in execution_result.protocol_results {
if let ActionResult::Output(data) = protocol_result {
if !one_reply.admit(&data) {
continue;
}
Self::write_out(write_half, &data, app_state, server_id, connection_id)
.await?;
}
}
one_reply.log_drops(&log, connection_id, "greeting");
}
Err(e) => {
let failure = crate::utils::WireFailure::classify(&e);
let notice = failure.prefixed_text();
let decision = if failure.is_overloaded() {
"fail_closed_llm_overloaded"
} else {
"fail_closed_llm_error"
};
Log::new(Some(status_tx)).error(format!(
"FTP greeting on connection {connection_id} decision={decision}, refused \
with 421 ({notice}): {e}"
));
let reply =
format!("421 Service not available, closing control connection ({notice})\r\n");
let _ = Self::write_out(
write_half,
reply.as_bytes(),
app_state,
server_id,
connection_id,
)
.await;
return Err(anyhow::anyhow!("FTP greeting refused with 421: {e}"));
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_session_commands<R, W>(
read_half: R,
write_half: &Arc<Mutex<W>>,
connection_id: crate::server::connection::ConnectionId,
server_id: crate::state::ServerId,
llm_client: OllamaClient,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
protocol: Arc<FtpProtocol>,
deadlines: ReadDeadlines,
) -> Result<()>
where
R: tokio::io::AsyncRead + Unpin,
W: tokio::io::AsyncWrite + Unpin,
{
use tokio::io::BufReader;
let mut reader = BufReader::new(read_half);
let log = Log::new(Some(status_tx));
let mut answered_one = false;
loop {
let read_timeout = if answered_one {
deadlines.idle
} else {
deadlines.first_byte
};
let (outcome, n) = match tokio::time::timeout(
read_timeout,
read_command_line(&mut reader, MAX_COMMAND_LINE),
)
.await
{
Ok(result) => result?,
Err(_) => {
log.info(format!(
"FTP connection {connection_id} sent nothing for {}s; closing idle \
control connection",
read_timeout.as_secs()
));
return Ok(());
}
};
answered_one = true;
let line = match outcome {
CommandLine::Eof => break,
CommandLine::TooLong => {
log.warn(format!(
"FTP command line from connection {connection_id} \
decision=refused_line_too_long: exceeded {MAX_COMMAND_LINE} bytes \
without a newline; answered 500 and closing"
));
let _ = Self::write_out(
write_half,
b"500 Command line too long\r\n",
app_state,
server_id,
connection_id,
)
.await;
return Ok(());
}
CommandLine::Line(line) => line,
};
app_state
.update_connection_stats(
server_id,
connection_id,
Some(n as u64),
None,
Some(1),
None,
)
.await;
let command = line.trim();
log.debug(format!("FTP received: {}", command));
let event = Event::new(&FTP_COMMAND_EVENT, {
let mut data = serde_json::json!({ "command": command });
if let Some(hint) = actions::answer_with_for_command(&command) {
data["answer_with"] = serde_json::json!(hint);
}
data
});
match call_llm(
&llm_client,
app_state,
server_id,
Some(connection_id),
&event,
protocol.as_ref(),
)
.await
{
Ok(execution_result) => {
let (decision, detail) = classify_ftp_outcome(&execution_result);
match decision {
"model_answer" => log.info(format!(
"FTP {command:?} on connection {connection_id} \
decision=model_answer ({detail})"
)),
"model_reject" => log.info(format!(
"FTP {command:?} on connection {connection_id} \
decision=model_reject ({detail})"
)),
"model_wait_for_more" => log.info(format!(
"FTP {command:?} on connection {connection_id} \
decision=model_wait_for_more ({detail})"
)),
"fail_closed_bad_action" => log.error(format!(
"FTP {command:?} on connection {connection_id} \
decision=fail_closed_bad_action ({detail}); nothing was written and \
the client is left waiting"
)),
_ => log.warn(format!(
"FTP {command:?} on connection {connection_id} \
decision=model_silent ({detail})"
)),
}
let mut one_reply = OneReply::default();
for protocol_result in execution_result.protocol_results {
match protocol_result {
ActionResult::Output(data) => {
if !one_reply.admit(&data) {
continue;
}
Self::write_out(
write_half,
&data,
app_state,
server_id,
connection_id,
)
.await?;
let response = String::from_utf8_lossy(&data);
log.debug(format!("FTP sent: {}", response.trim()));
}
ActionResult::CloseConnection => {
one_reply.log_drops(&log, connection_id, "reply");
return Ok(());
}
_ => {}
}
}
one_reply.log_drops(&log, connection_id, "reply");
}
Err(e) => {
let failure = crate::utils::WireFailure::classify(&e);
let notice = failure.prefixed_text();
let decision = if failure.is_overloaded() {
"fail_closed_llm_overloaded"
} else {
"fail_closed_llm_error"
};
log.error(format!(
"FTP {command:?} on connection {connection_id} decision={decision}, \
refused with 421 ({notice}): {e}"
));
let reply = format!(
"421 Service not available, closing control connection ({notice})\r\n"
);
let _ = Self::write_out(
write_half,
reply.as_bytes(),
app_state,
server_id,
connection_id,
)
.await;
return Ok(());
}
}
}
Ok(())
}
}