pub mod actions;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::body::{Body, Incoming};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc;
use tracing::{debug, error, trace, warn};
use crate::llm::action_helper::call_llm;
use crate::llm::ollama_client::OllamaClient;
use crate::logging::emit::Log;
use crate::protocol::Event;
use crate::server::connection::ConnectionId;
use crate::server::SamlSpProtocol;
use crate::state::app_state::AppState;
use actions::SAML_SP_REQUEST_EVENT;
pub const MAX_REQUEST_BYTES: usize = 256 * 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(60);
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";
fn status_or(value: Option<&serde_json::Value>, default: u16) -> u16 {
match value.and_then(|v| v.as_u64()) {
Some(raw) => u16::try_from(raw)
.ok()
.filter(|s| (100..=599).contains(s))
.unwrap_or_else(|| {
warn!("SAML SP: ignoring out-of-range status {raw}, using {default}");
default
}),
None => default,
}
}
fn build_safe_response(
status: u16,
headers: impl IntoIterator<Item = (String, String)>,
body: String,
) -> Response<Full<Bytes>> {
let status_code = StatusCode::from_u16(status).unwrap_or_else(|_| {
error!("SAML SP: invalid HTTP status {status}, sending 500 instead");
StatusCode::INTERNAL_SERVER_ERROR
});
let mut builder = Response::builder().status(status_code);
for (name, value) in headers {
match (
hyper::header::HeaderName::from_bytes(name.as_bytes()),
hyper::header::HeaderValue::from_str(&value),
) {
(Ok(n), Ok(v)) => builder = builder.header(n, v),
_ => warn!("SAML SP: dropping invalid response header {name:?}"),
}
}
builder
.body(Full::new(Bytes::from(body)))
.unwrap_or_else(|e| {
error!("SAML SP: failed to build response ({e}), sending bare 500");
let mut fallback =
Response::new(Full::new(Bytes::from_static(b"Internal Server Error")));
*fallback.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
fallback
})
}
pub struct SamlSpServer;
impl SamlSpServer {
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!("SAML SP server listening on {}", local_addr));
let protocol = Arc::new(SamlSpProtocol::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,
"SAML SP",
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!(
"Accepted SAML SP connection {} from {}",
connection_id, remote_addr
));
let status_tx_for_task = status_tx.clone();
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 protocol_clone = protocol.clone();
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id, async move {
let status_tx = status_tx_for_task;
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
);
let io = TokioIo::new(stream);
let llm_for_service = llm_client_clone.clone();
let state_for_service = app_state_clone.clone();
let status_for_service = status_tx.clone();
let protocol_for_service = protocol_clone.clone();
let activity = std::sync::Arc::new(
crate::server::accept_bounded::ConnectionActivity::new(),
);
let activity_for_service = std::sync::Arc::clone(&activity);
let service = service_fn(move |req: Request<Incoming>| {
let llm_clone = llm_for_service.clone();
let state_clone = state_for_service.clone();
let status_clone = status_for_service.clone();
let protocol_clone = protocol_for_service.clone();
let activity = std::sync::Arc::clone(&activity_for_service);
async move {
let _busy = activity.busy();
handle_saml_sp_request(
req,
connection_id,
server_id,
remote_addr,
llm_clone,
state_clone,
status_clone,
protocol_clone,
)
.await
}
});
if !spoke {
Log::new(Some(&status_tx)).debug(format!(
"SAML SP peer {} sent nothing for {}s; closing before \
any request",
remote_addr,
FIRST_BYTE_READ_TIMEOUT.as_secs()
));
} else {
let conn = http1::Builder::new()
.serve_connection(io, service)
.without_shutdown();
tokio::pin!(conn);
tokio::select! {
result = &mut conn => {
match result {
Ok(parts) => {
let mut stream = parts.io.into_inner();
let _ = stream.shutdown().await;
crate::server::accept_bounded::drain_after_response(&mut stream).await;
}
Err(err) => error!(
"Error serving SAML SP connection \
{}: {}",
connection_id,
err
),
}
}
_ = crate::server::accept_bounded::watch_idle(
std::sync::Arc::clone(&activity),
IDLE_BETWEEN_REQUESTS_TIMEOUT,
) => {
Log::new(Some(&status_tx)).debug(format!(
"SAML SP connection {} idle for {}s; closing",
connection_id,
IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
));
}
}
}
debug!("SAML SP connection {} closed", connection_id);
app_state_clone
.remove_connection_from_server(server_id, connection_id)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
})
.await;
}
Err(e) => {
Log::new(Some(&status_tx))
.error(format!("Failed to accept SAML SP connection: {}", e));
}
}
}
});
task_registrar
.register_server_task(server_id, accept_handle)
.await;
Ok(local_addr)
}
}
async fn handle_saml_sp_request(
req: Request<Incoming>,
connection_id: ConnectionId,
server_id: crate::state::ServerId,
remote_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
protocol: Arc<SamlSpProtocol>,
) -> Result<Response<Full<Bytes>>, Infallible> {
let method = req.method().clone();
let path = req.uri().path().to_string();
let query = req.uri().query().map(|q| q.to_string());
Log::new(Some(&status_tx)).debug(format!("SAML SP {} {} from {}", method, path, remote_addr));
let headers: Vec<(String, String)> = req
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body_bytes = match http_body_util::Limited::new(req.into_body(), MAX_REQUEST_BYTES)
.collect()
.await
{
Ok(collected) => collected.to_bytes().to_vec(),
Err(e) => {
Log::new(Some(&status_tx)).warn(format!(
"SAML SP {} {} decision=fail_closed_body_rejected (limit {} bytes): {}",
method, path, MAX_REQUEST_BYTES, e
));
return Ok(build_safe_response(
413,
[(
"content-type".to_string(),
"text/plain; charset=utf-8".to_string(),
)],
"request body too large".to_string(),
));
}
};
if !body_bytes.is_empty() {
trace!("SAML SP request body: {} bytes", body_bytes.len());
if let Ok(body_str) = String::from_utf8(body_bytes.clone()) {
trace!("SAML SP request body content: {}", body_str);
}
}
app_state
.update_connection_stats(
server_id,
connection_id,
Some(body_bytes.len() as u64),
None,
None,
None,
)
.await;
let event = Event::new(
&SAML_SP_REQUEST_EVENT,
serde_json::json!({
"method": method.to_string(),
"path": path.clone(),
"query": query,
"headers": headers,
"body": if body_bytes.is_empty() {
serde_json::Value::Null
} else if let Ok(body_str) = String::from_utf8(body_bytes.clone()) {
serde_json::Value::String(body_str)
} else {
serde_json::Value::String(format!(
"<{} bytes of non-UTF-8 data; not a SAML binding this server understands>",
body_bytes.len()
))
},
"client_ip": remote_addr.ip().to_string(),
}),
);
debug!("Calling LLM for SAML SP request decision");
let action_result = call_llm(
&llm_client,
&app_state,
server_id,
Some(connection_id),
&event,
protocol.as_ref(),
)
.await;
let response = match action_result {
Ok(result) => {
use crate::llm::actions::protocol_trait::ActionResult;
fn collect_outputs(result: ActionResult, out: &mut Vec<Vec<u8>>) {
match result {
ActionResult::Output(data) => out.push(data),
ActionResult::Multiple(inner) => {
for r in inner {
collect_outputs(r, out);
}
}
_ => {}
}
}
let mut outputs = Vec::new();
for protocol_result in result.protocol_results {
collect_outputs(protocol_result, &mut outputs);
}
let mut answer: Option<(u16, std::collections::HashMap<String, String>, String)> = None;
for output_data in outputs {
let Ok(json_value) = serde_json::from_slice::<serde_json::Value>(&output_data)
else {
warn!(
"SAML SP: ignoring non-JSON action output ({} bytes)",
output_data.len()
);
continue;
};
let (status_code, response_headers, response_body) =
answer.get_or_insert_with(|| {
(200u16, std::collections::HashMap::new(), String::new())
});
if json_value.get("status").is_some() {
*status_code = status_or(json_value.get("status"), 200);
}
if let Some(headers_obj) = json_value.get("headers").and_then(|v| v.as_object()) {
for (k, v) in headers_obj {
if let Some(v_str) = v.as_str() {
response_headers.insert(k.clone(), v_str.to_string());
}
}
}
if let Some(body) = json_value.get("body").and_then(|v| v.as_str()) {
*response_body = body.to_string();
}
}
match answer {
Some((status_code, response_headers, response_body)) => {
let decision = if status_code >= 400 {
"model_reject"
} else {
"model_answer"
};
debug!(
"SAML SP {} {} from {} decision={} status={}",
method, path, remote_addr, decision, status_code
);
build_safe_response(status_code, response_headers, response_body)
}
None => {
Log::new(Some(&status_tx)).warn(format!(
"SAML SP {} {} from {} decision=fail_closed_no_answer status=500 \
(no usable action output)",
method, path, remote_addr
));
build_safe_response(
500,
[(
"content-type".to_string(),
"text/plain; charset=utf-8".to_string(),
)],
crate::utils::WireFailure::Unavailable
.prefixed_text()
.to_string(),
)
}
}
}
Err(e) => {
let failure = crate::utils::WireFailure::classify(&e);
let overloaded = failure.is_overloaded();
let status = if overloaded { 503 } else { 500 };
Log::new(Some(&status_tx)).error(format!(
"SAML SP {} {} from {} decision=fail_closed_llm_error status={} overload={}: {}",
method, path, remote_addr, status, overloaded, e
));
let mut headers = vec![(
"content-type".to_string(),
"text/plain; charset=utf-8".to_string(),
)];
if overloaded {
headers.push(("retry-after".to_string(), "5".to_string()));
}
build_safe_response(status, headers, failure.prefixed_text().to_string())
}
};
let response_size = response.body().size_hint().exact().unwrap_or(0);
app_state
.update_connection_stats(
server_id,
connection_id,
None,
Some(response_size),
None,
None,
)
.await;
debug!(
"SAML SP response: {} ({} bytes)",
response.status(),
response_size
);
Ok(response)
}