pub mod actions;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
use bytes::Bytes;
use http_body_util::{BodyExt, Full, Limited};
use hyper::body::{Body, Incoming};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::sync::mpsc;
use tracing::error;
use crate::llm::ollama_client::OllamaClient;
use crate::llm::ActionResult;
use crate::logging::emit::Log;
use crate::server::accept_bounded::{
accept_bounded, watch_idle, ConnectionActivity, ConnectionLimiter,
};
use crate::server::connection::ConnectionId;
use crate::server::S3Protocol;
use crate::state::app_state::AppState;
pub const MAX_REQUEST_BYTES: usize = 8 * 1024 * 1024;
const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
pub struct S3Server;
impl S3Server {
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,
) -> anyhow::Result<SocketAddr> {
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!("S3 server listening on {}", local_addr));
let protocol = Arc::new(S3Protocol::new());
let task_registrar = app_state.clone();
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
let accept_handle = tokio::spawn(async move {
loop {
match accept_bounded(
&listener,
&limiter,
CONNECTION_CAP_REFUSAL,
"S3",
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);
Log::new(Some(&status_tx)).info(format!(
"S3 connection {} from {}",
connection_id, remote_addr
));
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(),
};
app_state
.add_connection_to_server(server_id, conn_state)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
let llm_client_clone = llm_client.clone();
let app_state_clone = app_state.clone();
let status_tx_clone = status_tx.clone();
let protocol_clone = protocol.clone();
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id, async move {
let _permit = permit;
let spoke = matches!(
tokio::time::timeout(
FIRST_BYTE_READ_TIMEOUT,
stream.peek(&mut [0u8; 1]),
)
.await,
Ok(Ok(n)) if n > 0
);
if spoke {
let io = TokioIo::new(stream);
let status_for_service = status_tx_clone.clone();
let app_state_for_service = app_state_clone.clone();
let activity = Arc::new(ConnectionActivity::new());
let activity_for_service = Arc::clone(&activity);
let service = service_fn(move |req: Request<Incoming>| {
let llm_clone = llm_client_clone.clone();
let state_clone = app_state_for_service.clone();
let status_clone = status_for_service.clone();
let protocol_clone = protocol_clone.clone();
let activity = Arc::clone(&activity_for_service);
async move {
let _busy = activity.busy();
handle_s3_request_with_llm(
req,
connection_id,
llm_clone,
state_clone,
status_clone,
protocol_clone,
server_id,
)
.await
}
});
let conn = http1::Builder::new().serve_connection(io, service);
tokio::pin!(conn);
tokio::select! {
result = &mut conn => {
if let Err(err) = result {
error!("Error serving S3 connection: {:?}", err);
}
}
_ = watch_idle(
Arc::clone(&activity),
IDLE_BETWEEN_REQUESTS_TIMEOUT,
) => {
Log::new(Some(&status_tx_clone)).debug(format!(
"S3 connection {} idle for {}s; closing",
connection_id,
IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
));
}
}
} else {
Log::new(Some(&status_tx_clone)).debug(format!(
"S3 peer {} sent no request within {}s; closing \
decision=fail_closed_first_byte_timeout",
remote_addr,
FIRST_BYTE_READ_TIMEOUT.as_secs()
));
}
app_state_clone
.close_connection_on_server(server_id, connection_id)
.await;
Log::new(Some(&status_tx_clone))
.info(format!("S3 connection {} closed", connection_id));
let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
})
.await;
}
Err(e) => {
Log::new(Some(&status_tx))
.error(format!("Failed to accept S3 connection: {}", e));
break;
}
}
}
});
task_registrar
.register_server_task(server_id, accept_handle)
.await;
Ok(local_addr)
}
}
async fn handle_s3_request_with_llm(
req: Request<Incoming>,
connection_id: ConnectionId,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
protocol: Arc<S3Protocol>,
server_id: crate::state::ServerId,
) -> Result<Response<Full<Bytes>>, Infallible> {
let received = req.body().size_hint().lower();
let response = handle_s3_request_inner(
req,
llm_client,
app_state.clone(),
status_tx.clone(),
protocol,
server_id,
)
.await;
let sent = response
.as_ref()
.ok()
.and_then(|resp| resp.body().size_hint().exact())
.unwrap_or(0);
app_state
.update_connection_stats(
server_id,
connection_id,
Some(received),
Some(sent),
Some(1),
Some(1),
)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
response
}
async fn handle_s3_request_inner(
req: Request<Incoming>,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
protocol: Arc<S3Protocol>,
server_id: crate::state::ServerId,
) -> Result<Response<Full<Bytes>>, Infallible> {
let method = req.method().clone();
let uri = req.uri().clone();
let path = uri.path().to_string();
let (bucket, key, operation) = parse_s3_path(&method, &path);
let log = Log::new(Some(&status_tx));
log.debug(format!(
"S3 request: {} {} bucket={:?} key={:?} operation={}",
method, path, bucket, key, operation
));
let body_bytes = match Limited::new(req.into_body(), MAX_REQUEST_BYTES)
.collect()
.await
{
Ok(collected) => collected.to_bytes(),
Err(e) => {
log.warn(format!(
"S3 {} {} decision=fail_closed_body_rejected (limit {} bytes): {}",
operation, path, MAX_REQUEST_BYTES, e
));
return Ok(Response::builder()
.status(StatusCode::PAYLOAD_TOO_LARGE)
.header("Content-Type", "application/xml")
.body(Full::new(Bytes::from(build_error_xml(
"EntityTooLarge",
"request body rejected",
))))
.unwrap());
}
};
if !body_bytes.is_empty() {
log.trace(format!("S3 request body ({} bytes)", body_bytes.len()));
}
let event = crate::protocol::Event::new(
&actions::S3_REQUEST_EVENT,
serde_json::json!({
"operation": operation,
"bucket": bucket,
"key": key,
"request_details": {
"method": method.as_str(),
"path": path,
"body_size": body_bytes.len(),
}
}),
);
let llm_result = crate::llm::action_helper::call_llm(
&llm_client,
&app_state,
server_id,
None, &event,
protocol.as_ref(),
)
.await;
match llm_result {
Ok(execution_result) => {
let decision = execution_result
.protocol_results
.iter()
.find_map(|result| match result {
ActionResult::Custom { name, .. } => match name.as_str() {
"s3_error" => Some("model_reject"),
"s3_object" | "s3_object_list" | "s3_bucket_list" | "s3_write_result" => {
Some("model_answer")
}
_ => None,
},
_ => None,
})
.unwrap_or("model_no_action");
let failures = execution_result.failures.len();
if let Some(response) = execution_result
.protocol_results
.into_iter()
.find_map(|result| process_s3_action_result(result, bucket.as_deref(), &status_tx))
{
log.debug(format!("S3 {} {} decision={}", operation, path, decision));
return Ok(response);
}
log.warn(format!(
"S3 {} {} decision={} (no S3 response action; {} action(s) failed to \
execute) — answering 500 InternalError",
operation, path, decision, failures
));
Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/xml")
.body(Full::new(Bytes::from(build_error_xml(
"InternalError",
crate::utils::WireFailure::Unavailable.text(),
))))
.unwrap())
}
Err(e) => {
let failure = crate::utils::WireFailure::classify(&e);
log.warn(format!(
"S3 {} {} decision=fail_closed_llm_error category={:?}: {}",
operation, path, failure, e
));
let (status, code) = match failure {
crate::utils::WireFailure::Overloaded => {
(StatusCode::SERVICE_UNAVAILABLE, "ServiceUnavailable")
}
crate::utils::WireFailure::Unavailable => {
(StatusCode::INTERNAL_SERVER_ERROR, "InternalError")
}
};
let mut builder = Response::builder()
.status(status)
.header("Content-Type", "application/xml");
if failure == crate::utils::WireFailure::Overloaded {
builder = builder.header("Retry-After", "5");
}
Ok(builder
.body(Full::new(Bytes::from(build_error_xml(
code,
failure.text(),
))))
.unwrap())
}
}
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
fn build_error_xml(code: &str, message: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>{}</Code>
<Message>{}</Message>
</Error>"#,
xml_escape(code),
xml_escape(message)
)
}
fn header_or_skip(
builder: hyper::http::response::Builder,
name: &'static str,
value: &str,
) -> hyper::http::response::Builder {
match hyper::header::HeaderValue::from_str(value) {
Ok(v) => builder.header(name, v),
Err(_) => {
error!(
"Dropping S3 {} header: {:?} is not a valid HTTP header value",
name, value
);
builder
}
}
}
fn parse_s3_path(method: &Method, path: &str) -> (Option<String>, Option<String>, String) {
let parts: Vec<&str> = path
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
match (method, parts.as_slice()) {
(m, []) if m == Method::GET => (None, None, "ListBuckets".to_string()),
(m, [bucket]) if m == Method::GET => {
(Some(bucket.to_string()), None, "ListObjects".to_string())
}
(m, [bucket]) if m == Method::PUT => {
(Some(bucket.to_string()), None, "CreateBucket".to_string())
}
(m, [bucket]) if m == Method::DELETE => {
(Some(bucket.to_string()), None, "DeleteBucket".to_string())
}
(m, [bucket]) if m == Method::HEAD => {
(Some(bucket.to_string()), None, "HeadBucket".to_string())
}
(m, parts) if parts.len() >= 2 && m == Method::GET => {
let bucket = parts[0].to_string();
let key = parts[1..].join("/");
(Some(bucket), Some(key), "GetObject".to_string())
}
(m, parts) if parts.len() >= 2 && m == Method::PUT => {
let bucket = parts[0].to_string();
let key = parts[1..].join("/");
(Some(bucket), Some(key), "PutObject".to_string())
}
(m, parts) if parts.len() >= 2 && m == Method::DELETE => {
let bucket = parts[0].to_string();
let key = parts[1..].join("/");
(Some(bucket), Some(key), "DeleteObject".to_string())
}
(m, parts) if parts.len() >= 2 && m == Method::HEAD => {
let bucket = parts[0].to_string();
let key = parts[1..].join("/");
(Some(bucket), Some(key), "HeadObject".to_string())
}
_ => (None, None, "Unknown".to_string()),
}
}
fn process_s3_action_result(
action_result: ActionResult,
bucket: Option<&str>,
status_tx: &mpsc::UnboundedSender<String>,
) -> Option<Response<Full<Bytes>>> {
match action_result {
ActionResult::Custom { name, data } => {
match name.as_str() {
"s3_object" => {
use base64::Engine;
let content: Vec<u8> = data
.get("content_b64")
.and_then(|v| v.as_str())
.and_then(|s| base64::engine::general_purpose::STANDARD.decode(s).ok())
.unwrap_or_default();
let content_type = data
.get("content_type")
.and_then(|v| v.as_str())
.unwrap_or("application/octet-stream");
let etag = data.get("etag").and_then(|v| v.as_str());
Log::new(Some(status_tx)).debug(format!(
"Sending S3 object ({} bytes, {})",
content.len(),
content_type
));
let content_type = if hyper::header::HeaderValue::from_str(content_type).is_ok()
{
content_type
} else {
error!(
"S3 content_type {:?} is not a valid header value; \
sending application/octet-stream",
content_type
);
"application/octet-stream"
};
let mut builder = Response::builder().status(StatusCode::OK);
builder = header_or_skip(builder, "Content-Type", content_type);
if let Some(etag) = etag {
builder = header_or_skip(builder, "ETag", etag);
}
Some(
builder
.body(Full::new(Bytes::from(content)))
.unwrap_or_else(|_| Response::new(Full::new(Bytes::new()))),
)
}
"s3_object_list" => {
let objects = data
.get("objects")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let is_truncated = data
.get("is_truncated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let xml = build_list_objects_xml(bucket, &objects, is_truncated);
Log::new(Some(status_tx)).debug(format!(
"Sending S3 object list ({} objects)",
objects.len()
));
Some(
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/xml")
.body(Full::new(Bytes::from(xml)))
.unwrap(),
)
}
"s3_bucket_list" => {
let buckets = data
.get("buckets")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let xml = build_list_buckets_xml(&buckets);
Log::new(Some(status_tx)).debug(format!(
"Sending S3 bucket list ({} buckets)",
buckets.len()
));
Some(
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/xml")
.body(Full::new(Bytes::from(xml)))
.unwrap(),
)
}
"s3_write_result" => {
let status = data
.get("status_code")
.and_then(|v| v.as_u64())
.unwrap_or(200) as u16;
let status =
StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
Log::new(Some(status_tx))
.debug(format!("Sending S3 write acknowledgement ({})", status));
let mut builder = Response::builder().status(status);
for (field, header) in [
("etag", "ETag"),
("location", "Location"),
("content_type", "Content-Type"),
("last_modified", "Last-Modified"),
] {
if let Some(v) = data.get(field).and_then(|v| v.as_str()) {
builder = header_or_skip(builder, header, v);
}
}
if let Some(v) = data.get("content_length").and_then(|v| v.as_u64()) {
builder = builder.header("Content-Length", v.to_string());
}
Some(
builder
.body(Full::new(Bytes::new()))
.unwrap_or_else(|_| Response::new(Full::new(Bytes::new()))),
)
}
"s3_error" => {
let error_code = data
.get("error_code")
.and_then(|v| v.as_str())
.unwrap_or("InternalError");
let message = data
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("An error occurred");
let status_code = data
.get("status_code")
.and_then(|v| v.as_u64())
.unwrap_or(500) as u16;
let xml = build_error_xml(error_code, message);
Log::new(Some(status_tx)).debug(format!(
"Sending S3 error: {} ({})",
error_code, status_code
));
Some(
Response::builder()
.status(
StatusCode::from_u16(status_code)
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
)
.header("Content-Type", "application/xml")
.body(Full::new(Bytes::from(xml)))
.unwrap(),
)
}
_ => {
None
}
}
}
_ => {
None
}
}
}
fn build_list_buckets_xml(buckets: &[serde_json::Value]) -> String {
let mut xml = String::from(
r#"<?xml version="1.0" encoding="UTF-8"?>
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Owner>
<DisplayName>netget</DisplayName>
<ID>netget-user</ID>
</Owner>
<Buckets>"#,
);
for bucket in buckets {
let name = bucket.get("name").and_then(|v| v.as_str()).unwrap_or("");
let creation_date = bucket
.get("creation_date")
.and_then(|v| v.as_str())
.unwrap_or("2024-01-01T00:00:00.000Z");
xml.push_str(&format!(
r#"
<Bucket>
<Name>{}</Name>
<CreationDate>{}</CreationDate>
</Bucket>"#,
xml_escape(name),
xml_escape(creation_date)
));
}
xml.push_str(
r#"
</Buckets>
</ListAllMyBucketsResult>"#,
);
xml
}
fn build_list_objects_xml(
bucket: Option<&str>,
objects: &[serde_json::Value],
is_truncated: bool,
) -> String {
let mut xml = String::from(
r#"<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">"#,
);
xml.push_str(&format!(
r#"
<Name>{}</Name>
<Prefix></Prefix>
<MaxKeys>1000</MaxKeys>
<KeyCount>{}</KeyCount>
<IsTruncated>{}</IsTruncated>"#,
xml_escape(bucket.unwrap_or("")),
objects.len(),
is_truncated
));
for object in objects {
let key = object.get("key").and_then(|v| v.as_str()).unwrap_or("");
let size = object.get("size").and_then(|v| v.as_u64()).unwrap_or(0);
let last_modified = object
.get("last_modified")
.and_then(|v| v.as_str())
.unwrap_or("2024-01-01T00:00:00.000Z");
let etag = object
.get("etag")
.and_then(|v| v.as_str())
.unwrap_or("\"default\"");
xml.push_str(&format!(
r#"
<Contents>
<Key>{}</Key>
<Size>{}</Size>
<LastModified>{}</LastModified>
<ETag>{}</ETag>
<StorageClass>STANDARD</StorageClass>
</Contents>"#,
xml_escape(key),
size,
xml_escape(last_modified),
xml_escape(etag)
));
}
xml.push_str(
r#"
</ListBucketResult>"#,
);
xml
}