use axum::{
extract::{DefaultBodyLimit, State},
http::{header::AUTHORIZATION, HeaderMap, StatusCode},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::net::TcpListener;
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
const MAX_BODY_BYTES: usize = 256 * 1024;
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub port: u16,
pub state_dir: PathBuf,
pub token: Option<String>,
}
impl ServerConfig {
pub fn new(port: u16, state_dir: PathBuf) -> Self {
Self {
port,
state_dir,
token: None,
}
}
pub fn with_token(mut self, token: Option<String>) -> Self {
self.token = token.filter(|t| !t.is_empty());
self
}
}
pub struct ServerState {
#[allow(dead_code)]
state_dir: PathBuf,
token: Option<String>,
}
impl ServerState {
pub fn new(config: &ServerConfig) -> Self {
Self {
state_dir: config.state_dir.clone(),
token: config.token.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BehavioralSession {
pub session: serde_json::Value,
}
#[derive(Debug, Clone, Serialize)]
pub struct CollectResponse {
pub status: String,
pub message: String,
}
#[derive(Serialize)]
pub struct HealthResponse {
pub status: String,
pub version: String,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
pub code: String,
}
async fn health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
})
}
fn check_auth(
state: &ServerState,
headers: &HeaderMap,
) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
let Some(expected) = state.token.as_deref() else {
return Ok(());
};
let provided = headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
match provided {
Some(token) if constant_time_eq(token.as_bytes(), expected.as_bytes()) => Ok(()),
_ => Err((
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "Missing or invalid bearer token".to_string(),
code: "UNAUTHORIZED".to_string(),
}),
)),
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
async fn collect(
State(state): State<Arc<ServerState>>,
headers: HeaderMap,
Json(data): Json<BehavioralSession>,
) -> Result<Json<CollectResponse>, (StatusCode, Json<ErrorResponse>)> {
check_auth(&state, &headers)?;
if !data.session.is_object() {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Session payload must be a JSON object".to_string(),
code: "INVALID_SESSION".to_string(),
}),
));
}
Ok(Json(CollectResponse {
status: "ok".to_string(),
message: "Data accepted".to_string(),
}))
}
pub async fn run(
config: ServerConfig,
) -> anyhow::Result<(SocketAddr, tokio::sync::oneshot::Sender<()>)> {
let state = Arc::new(ServerState::new(&config));
let app = Router::new()
.route("/health", get(health))
.route("/collect", post(collect))
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.layer(
CorsLayer::new()
.allow_origin(AllowOrigin::predicate(|origin, _| {
let Ok(origin) = origin.to_str() else {
return false;
};
origin.starts_with("chrome-extension://")
|| origin == "http://localhost"
|| origin.starts_with("http://localhost:")
|| origin == "http://127.0.0.1"
|| origin.starts_with("http://127.0.0.1:")
}))
.allow_methods(Any)
.allow_headers(Any),
)
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], config.port));
let listener = TcpListener::bind(addr).await?;
let actual_addr = listener.local_addr()?;
tracing::info!("Sensor agent server listening on http://{}", actual_addr);
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
tracing::info!("Server shutdown signal received");
})
.await
{
tracing::error!("Server error: {}", e);
}
});
Ok((actual_addr, shutdown_tx))
}