pub mod actions;
pub mod extensions;
use crate::server::connection::ConnectionId;
use anyhow::Result;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncWriteExt, BufReader};
use tokio::sync::mpsc;
use crate::llm::action_helper::call_llm;
use crate::llm::actions::protocol_trait::ActionResult;
use crate::llm::ollama_client::OllamaClient;
use crate::logging::emit::Log;
use crate::protocol::Event;
use crate::server::NntpProtocol;
use crate::state::app_state::AppState;
use actions::NNTP_COMMAND_RECEIVED_EVENT;
pub const MAX_COMMAND_BYTES: usize = 4096;
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(600);
const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
const CONNECTION_CAP_REFUSAL: &[u8] = b"400 too many connections\r\n";
pub struct NntpServer;
impl NntpServer {
#[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>,
tls: Option<tokio_rustls::TlsAcceptor>,
require_auth: bool,
) -> 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!("NNTP server listening on {}", local_addr));
let protocol = Arc::new(NntpProtocol::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,
"NNTP",
Some(&status_tx),
)
.await
{
Ok((stream, remote_addr, permit)) => {
let connection_id =
ConnectionId::new(app_state.get_next_unified_id().await);
let local_addr_conn = stream.local_addr().unwrap_or(local_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 tls = tls.clone();
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id, async move {
let _permit = permit;
let secure = tls.is_some();
let stream: crate::server::p2p_support::Stream =
if let Some(tls) = tls {
let Ok(Ok(stream)) = tokio::time::timeout(
Duration::from_secs(10),
tls.accept(stream),
)
.await
else {
return;
};
Box::new(stream)
} else {
Box::new(stream)
};
Self::handle_connection(
stream,
connection_id,
remote_addr,
local_addr_conn,
server_id,
llm_clone,
state_clone,
status_clone,
protocol_clone,
deadlines,
secure,
require_auth,
)
.await;
})
.await;
}
Err(e) => {
Log::new(Some(&status_tx))
.error(format!("Failed to accept NNTP connection: {}", e));
}
}
}
});
task_registrar
.register_server_task(server_id, accept_handle)
.await;
Ok(local_addr)
}
#[allow(clippy::too_many_arguments)]
async fn handle_connection(
stream: crate::server::p2p_support::Stream,
connection_id: ConnectionId,
remote_addr: SocketAddr,
local_addr: SocketAddr,
server_id: crate::state::ServerId,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
protocol: Arc<NntpProtocol>,
deadlines: ReadDeadlines,
secure: bool,
require_auth: bool,
) {
use crate::state::server::{
ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
};
let (read_half, write_half) = tokio::io::split(stream);
let write_half_arc = Arc::new(tokio::sync::Mutex::new(write_half));
let now = crate::utils::clock::Instant::now();
let conn_state = ServerConnectionState {
id: connection_id,
remote_addr,
local_addr,
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(),
};
app_state
.add_connection_to_server(server_id, conn_state)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
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_arc.clone(),
status_tx.clone(),
);
Self::run_session(
BufReader::new(read_half),
&write_half_arc,
connection_id,
server_id,
&llm_client,
&app_state,
&status_tx,
&protocol,
deadlines,
secure,
require_auth,
)
.await;
app_state
.remove_peer_handle(server_id, connection_id.as_u32())
.await;
app_state
.remove_connection_from_server(server_id, connection_id)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
}
async fn write_counted<W>(
write_half: &Arc<tokio::sync::Mutex<W>>,
app_state: &AppState,
server_id: crate::state::ServerId,
connection_id: ConnectionId,
data: &[u8],
shutdown: bool,
) where
W: tokio::io::AsyncWrite + Unpin,
{
{
let mut write = write_half.lock().await;
let _ = write.write_all(data).await;
let _ = write.flush().await;
if shutdown {
let _ = write.shutdown().await;
}
}
app_state
.update_connection_stats(
server_id,
connection_id,
None,
Some(data.len() as u64),
None,
Some(1),
)
.await;
}
#[allow(clippy::too_many_arguments)]
async fn run_session<R, W>(
mut reader: BufReader<R>,
write_half_arc: &Arc<tokio::sync::Mutex<W>>,
connection_id: ConnectionId,
server_id: crate::state::ServerId,
llm_client: &OllamaClient,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
protocol: &Arc<NntpProtocol>,
deadlines: ReadDeadlines,
secure: bool,
require_auth: bool,
) where
R: tokio::io::AsyncRead + Unpin,
W: tokio::io::AsyncWrite + Unpin,
{
let log = Log::new(Some(status_tx));
log.debug(format!(
"NNTP sending greeting to connection {}",
connection_id
));
let greeting_event = Event::new(
&NNTP_COMMAND_RECEIVED_EVENT,
nntp_command_event_data("GREETING"),
);
let greeting_code: Option<String>;
match call_llm(
llm_client,
app_state,
server_id,
Some(connection_id),
&greeting_event,
protocol.as_ref(),
)
.await
{
Ok(execution_result) => {
for message in &execution_result.messages {
log.info(message);
}
let plan = SessionWrite::collect(&execution_result, &[]);
plan.log_dropped(&log, connection_id, "GREETING");
greeting_code = plan
.outputs
.first()
.and_then(|data| data.get(..3))
.map(|code| String::from_utf8_lossy(code).to_string());
for data in &plan.outputs {
Self::write_counted(
write_half_arc,
app_state,
server_id,
connection_id,
data,
false,
)
.await;
let response = String::from_utf8_lossy(data);
let preview = crate::utils::truncate_for_log(&response, 100);
log.debug(format!(
"NNTP sent {} bytes on connection {} (decision={}): {}",
data.len(),
connection_id,
nntp_model_decision(data),
preview.trim()
));
log.trace(format!("NNTP sent (text): {:?}", response.trim()));
}
if plan.wrote_nothing() && !plan.silence_is_deliberate {
let decision = if execution_result.failures.is_empty() {
"fail_closed_no_actions"
} else {
"fail_closed_action_error"
};
let line = nntp_greeting_failure_line_static();
log.warn(format!(
"NNTP greeting produced no response on connection {} \
(decision={}, refused: {}): {}",
connection_id,
decision,
line.trim_end(),
nntp_failure_detail(&execution_result)
));
Self::write_counted(
write_half_arc,
app_state,
server_id,
connection_id,
line.as_bytes(),
true,
)
.await;
return;
}
if plan.close {
return;
}
}
Err(e) => {
let line = nntp_greeting_failure_line(&e);
log.warn(format!(
"NNTP greeting LLM error on connection {} \
(decision=fail_closed_llm_error, refused: {}): {}",
connection_id,
line.trim_end(),
e
));
Self::write_counted(
write_half_arc,
app_state,
server_id,
connection_id,
line.as_bytes(),
true,
)
.await;
return;
}
}
let mut extensions = extensions::State {
secure,
require_auth,
authenticated: !require_auth,
posting_allowed: greeting_code.as_deref() == Some("200"),
..Default::default()
};
let mut answered_one = false;
loop {
let read_timeout = if answered_one {
deadlines.idle
} else {
deadlines.first_byte
};
let framed = tokio::time::timeout(
read_timeout,
crate::utils::line_reader::read_bounded_line(&mut reader, MAX_COMMAND_BYTES),
)
.await;
let (read, n) = match framed {
Err(_) => {
log.info(format!(
"NNTP connection {} sent nothing for {}s; closing idle connection",
connection_id,
read_timeout.as_secs()
));
break;
}
Ok(Ok(v)) => v,
Ok(Err(_)) => break,
};
answered_one = true;
let line = match read {
crate::utils::line_reader::BoundedLine::Line(line) => line,
crate::utils::line_reader::BoundedLine::Eof => break,
crate::utils::line_reader::BoundedLine::TooLong => {
log.error(format!(
"NNTP connection {} sent a command line over {} bytes with no newline; \
decision=fail_closed_oversized_command",
connection_id, MAX_COMMAND_BYTES
));
Self::write_counted(
write_half_arc,
app_state,
server_id,
connection_id,
b"501 command line too long\r\n",
true,
)
.await;
break;
}
};
app_state
.update_connection_stats(
server_id,
connection_id,
Some(n as u64),
None,
Some(1),
None,
)
.await;
let safe_line = if line.to_ascii_uppercase().starts_with("AUTHINFO PASS ") {
"AUTHINFO PASS [redacted]"
} else {
&line
};
let preview = crate::utils::truncate_for_log(safe_line, 100);
log.debug(format!(
"NNTP received {} bytes on connection {}: {}",
n,
connection_id,
preview.trim()
));
log.trace(format!("NNTP data (text): {:?}", safe_line.trim()));
let extension_ctx = extensions::ContextRefs {
llm: llm_client,
state: app_state,
server: server_id,
peer: connection_id,
protocol: protocol.as_ref(),
};
match extensions::handle(
&mut reader,
write_half_arc,
line.trim_end_matches(['\r', '\n']),
&mut extensions,
&extension_ctx,
)
.await
{
Ok(true) => continue,
Ok(false) => {}
Err(e) => {
log.warn(format!("NNTP extension ended: {e}"));
break;
}
}
if line.trim().eq_ignore_ascii_case("CAPABILITIES") {
let mut reply = String::from_utf8_lossy(CAPABILITIES_REPLY).to_string();
if !extensions.posting_allowed {
reply = reply.replace("POST\r\n", "");
}
if secure {
reply = reply.replace(".\r\n", "AUTHINFO USER\r\n.\r\n");
}
Self::write_counted(
write_half_arc,
app_state,
server_id,
connection_id,
reply.as_bytes(),
false,
)
.await;
continue;
}
if let Some(reply) = netget_answer(line.trim(), greeting_code.as_deref()) {
log.info(format!(
"NNTP {} on connection {} decision=netget_answer: answered by NetGet",
crate::utils::truncate_for_log(line.trim(), 60),
connection_id
));
Self::write_counted(
write_half_arc,
app_state,
server_id,
connection_id,
reply,
false,
)
.await;
continue;
}
let event = Event::new(
&NNTP_COMMAND_RECEIVED_EVENT,
nntp_command_event_data(line.trim()),
);
log.debug(format!("NNTP calling LLM for connection {}", connection_id));
match call_llm(
llm_client,
app_state,
server_id,
Some(connection_id),
&event,
protocol.as_ref(),
)
.await
{
Ok(execution_result) => {
for message in &execution_result.messages {
log.info(message);
}
log.debug(format!(
"NNTP got {} protocol results",
execution_result.protocol_results.len()
));
let plan = SessionWrite::collect(
&execution_result,
nntp_preferred_actions(line.trim()),
);
plan.log_dropped(&log, connection_id, line.trim());
if plan.wrote_nothing() && !plan.silence_is_deliberate {
let decision = if execution_result.failures.is_empty() {
"fail_closed_no_actions"
} else {
"fail_closed_action_error"
};
let reply = nntp_command_failure_line_static();
log.warn(format!(
"NNTP produced no response on connection {} \
(decision={}, replying: {}): {}",
connection_id,
decision,
reply.trim_end(),
nntp_failure_detail(&execution_result)
));
Self::write_counted(
write_half_arc,
app_state,
server_id,
connection_id,
reply.as_bytes(),
false,
)
.await;
}
for data in &plan.outputs {
Self::write_counted(
write_half_arc,
app_state,
server_id,
connection_id,
data,
false,
)
.await;
let response = String::from_utf8_lossy(data);
let preview = crate::utils::truncate_for_log(&response, 100);
log.debug(format!(
"NNTP sent {} bytes on connection {} (decision={}): {}",
data.len(),
connection_id,
nntp_model_decision(data),
preview.trim()
));
log.trace(format!("NNTP sent (text): {:?}", response.trim()));
}
if plan.close {
break;
}
}
Err(e) => {
let (reply, close) = nntp_command_failure_line(&e);
log.warn(format!(
"NNTP LLM error on connection {} \
(decision=fail_closed_llm_error, replying: {}): {}",
connection_id,
reply.trim_end(),
e
));
Self::write_counted(
write_half_arc,
app_state,
server_id,
connection_id,
reply.as_bytes(),
close,
)
.await;
if close {
break;
}
}
}
}
log.info(format!("NNTP connection {} closed", connection_id));
}
}
pub const CAPABILITIES_REPLY: &[u8] =
b"101 Capability list:\r\nVERSION 2\r\nREADER\r\nLIST ACTIVE\r\nOVER\r\nPOST\r\nIHAVE\r\nSTREAMING\r\n.\r\n";
pub fn mode_reader_reply(greeting_code: Option<&str>) -> &'static [u8] {
if greeting_code == Some("200") {
b"200 Reader mode, posting allowed\r\n"
} else {
b"201 Reader mode, posting prohibited\r\n"
}
}
pub fn netget_answer(command: &str, greeting_code: Option<&str>) -> Option<&'static [u8]> {
let upper = command.trim().to_ascii_uppercase();
let mut words = upper.split_whitespace();
match (words.next(), words.next(), words.next()) {
(Some("CAPABILITIES"), _, None) => Some(CAPABILITIES_REPLY),
(Some("MODE"), Some("READER"), None) => Some(mode_reader_reply(greeting_code)),
_ => None,
}
}
pub fn nntp_preferred_actions(command: &str) -> &'static [&'static str] {
let mut words = command.split_whitespace();
let verb = words.next().unwrap_or("").to_ascii_uppercase();
let arg = words.next().map(str::to_ascii_uppercase);
match (verb.as_str(), arg.as_deref()) {
("GROUP", _) => &["send_nntp_group"],
("LIST", None) | ("LIST", Some("ACTIVE")) => &["send_nntp_list"],
("ARTICLE" | "HEAD" | "BODY", _) => &["send_nntp_article"],
("OVER" | "XOVER", _) => &["send_nntp_overview"],
_ => &[],
}
}
pub fn nntp_command_event_data(command: &str) -> serde_json::Value {
let mut data = serde_json::json!({ "command": command });
if command == "GREETING" {
data["answer_with"] = "send_nntp_response with code 200 (posting allowed) or 201 \
(posting prohibited) and the server's banner as text"
.into();
return data;
}
let mut words = command.split_whitespace();
let verb = words.next().unwrap_or("").to_ascii_uppercase();
let arg = words.next();
let answer = match (verb.as_str(), arg) {
("GROUP", Some(group)) => {
data["group"] = group.into();
format!(
"send_nntp_group with name {group} and its count, low and high article numbers \
if the group {group} exists; if it does not, send_nntp_response with code 411 \
and text \"No such newsgroup\""
)
}
("LIST", None) => "send_nntp_list with every group the server carries (name, high, \
low, status \"y\")"
.to_string(),
("LIST", Some(kind)) if kind.eq_ignore_ascii_case("ACTIVE") => {
"send_nntp_list with every group the server carries (name, high, low, status \"y\")"
.to_string()
}
("ARTICLE" | "HEAD" | "BODY", article) => {
if let Some(article) = article {
data["article"] = article.into();
}
let code = match verb.as_str() {
"ARTICLE" => 220,
"HEAD" => 221,
_ => 222,
};
format!(
"send_nntp_article with code {code}; send_nntp_response with code 423 if there \
is no such article in the group, 430 if there is no article with that \
message-id, or 412 if no group has been selected"
)
}
("OVER" | "XOVER", _) => "send_nntp_overview with one entry per article in the range; \
send_nntp_response with code 412 if no group has been selected"
.to_string(),
("QUIT", _) => "send_nntp_response with code 205, then close_connection".to_string(),
_ => return data,
};
data["answer_with"] = answer.into();
data
}
#[derive(Default)]
struct SessionWrite {
outputs: Vec<Vec<u8>>,
dropped: usize,
close: bool,
silence_is_deliberate: bool,
}
impl SessionWrite {
fn collect(
result: &crate::llm::actions::executor::ExecutionResult,
preferred: &[&str],
) -> Self {
let chosen = result.chosen_reply(preferred);
let mut plan = Self::default();
for (index, action_result) in result.protocol_results.iter().enumerate() {
plan.absorb(std::slice::from_ref(action_result), Some(index) == chosen);
}
plan
}
fn absorb(&mut self, results: &[ActionResult], is_the_reply: bool) {
for result in results {
match result {
ActionResult::Output(_) if !is_the_reply || !self.outputs.is_empty() => {
self.dropped += 1
}
ActionResult::Output(data) => self.outputs.push(data.clone()),
ActionResult::CloseConnection => {
self.close = true;
self.silence_is_deliberate = true;
}
ActionResult::WaitForMore => self.silence_is_deliberate = true,
ActionResult::Multiple(inner) => self.absorb(inner, is_the_reply),
_ => {}
}
}
}
fn wrote_nothing(&self) -> bool {
self.outputs.is_empty()
}
fn log_dropped(&self, log: &Log, connection_id: ConnectionId, command: &str) {
if self.dropped > 0 {
log.warn(format!(
"NNTP {} on connection {} decision=duplicate_response_dropped: {} extra \
repl{} not sent; one command gets one reply",
crate::utils::truncate_for_log(command, 60),
connection_id,
self.dropped,
if self.dropped == 1 { "y" } else { "ies" }
));
}
}
}
fn nntp_model_decision(data: &[u8]) -> &'static str {
match data.first() {
Some(b'4') | Some(b'5') => "model_reject",
_ => "model_answer",
}
}
fn nntp_failure_detail(result: &crate::llm::actions::executor::ExecutionResult) -> String {
if result.failures.is_empty() {
"the answer contained no actions".to_string()
} else {
result
.failures
.iter()
.map(|f| format!("{}: {}", f.action, f.error))
.collect::<Vec<_>>()
.join("; ")
}
}
fn nntp_greeting_failure_line_static() -> String {
format!(
"400 {}\r\n",
crate::utils::WireFailure::Unavailable.prefixed_text()
)
}
fn nntp_command_failure_line_static() -> String {
format!(
"403 {}\r\n",
crate::utils::WireFailure::Unavailable.prefixed_text()
)
}
fn nntp_greeting_failure_line(err: &anyhow::Error) -> String {
format!(
"400 {}\r\n",
crate::utils::WireFailure::classify(err).prefixed_text()
)
}
fn nntp_command_failure_line(err: &anyhow::Error) -> (String, bool) {
let failure = crate::utils::WireFailure::classify(err);
let text = failure.prefixed_text();
if failure.is_overloaded() {
(format!("400 {text}\r\n"), true)
} else {
(format!("403 {text}\r\n"), false)
}
}