use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::{
extract::{Request, State},
http::{header, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use parking_lot::Mutex;
use serde::Serialize;
use super::agent::{AgentIdentity, AgentRegistry};
pub struct RateLimiter {
capacity: u32,
refill_rate: u32,
tokens: AtomicU64,
last_refill: Mutex<Instant>,
}
impl RateLimiter {
pub fn new(requests_per_second: u32, burst_size: u32) -> Self {
Self {
capacity: burst_size,
refill_rate: requests_per_second,
tokens: AtomicU64::new((burst_size as u64).saturating_mul(1000)),
last_refill: Mutex::new(Instant::now()),
}
}
pub fn try_acquire(&self) -> bool {
self.refill();
loop {
let current = self.tokens.load(Ordering::Relaxed);
if current < 1000 {
return false; }
let new_tokens = current.saturating_sub(1000);
if self
.tokens
.compare_exchange(current, new_tokens, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
return true;
}
}
}
fn refill(&self) {
let mut last_refill = self.last_refill.lock();
let now = Instant::now();
let elapsed = now.duration_since(*last_refill);
if elapsed >= Duration::from_millis(1) {
let tokens_to_add_f = elapsed.as_secs_f64() * self.refill_rate as f64 * 1000.0;
let tokens_to_add = tokens_to_add_f as u64;
let max_tokens = (self.capacity as u64).saturating_mul(1000);
let current = self.tokens.load(Ordering::Relaxed);
let new_tokens = current.saturating_add(tokens_to_add).min(max_tokens);
self.tokens.store(new_tokens, Ordering::Relaxed);
*last_refill = now;
}
}
pub fn available_tokens(&self) -> u32 {
self.refill();
(self.tokens.load(Ordering::Relaxed) / 1000) as u32
}
pub fn capacity(&self) -> u32 {
self.capacity
}
pub fn refill_rate(&self) -> u32 {
self.refill_rate
}
}
fn constant_time_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
let mut result: u8 = 0;
for (x, y) in a.bytes().zip(b.bytes()) {
result |= x ^ y;
}
result == 0
}
const WEB_UI_ALLOWLIST: &[&str] = &[
"/stats",
"/stats/queries",
"/metrics",
"/audit",
"/circuit",
"/queue",
"/clients",
"/cache/integrity",
"/cache/upstreams",
"/contexts",
"/contexts/current",
];
#[derive(Clone)]
pub struct AuthConfig {
pub api_key: Option<String>,
pub agent_registry: Option<Arc<AgentRegistry>>,
pub web_ui_enabled: bool,
}
impl AuthConfig {
pub fn with_key(key: String) -> Self {
Self {
api_key: Some(key),
agent_registry: None,
web_ui_enabled: false,
}
}
pub fn disabled() -> Self {
Self {
api_key: None,
agent_registry: None,
web_ui_enabled: false,
}
}
pub fn with_registry(mut self, registry: Arc<AgentRegistry>) -> Self {
self.agent_registry = Some(registry);
self
}
pub fn with_web_ui(mut self) -> Self {
self.web_ui_enabled = true;
self
}
}
#[derive(Serialize)]
pub struct RateLimitResponse {
pub error: &'static str,
pub retry_after_ms: u64,
}
#[derive(Serialize)]
pub struct AuthErrorResponse {
pub error: &'static str,
}
fn extract_api_key(request: &Request) -> Option<String> {
if let Some(key) = request.headers().get("x-api-key") {
return key.to_str().ok().map(|s| s.to_string());
}
if let Some(auth) = request.headers().get(header::AUTHORIZATION) {
if let Ok(auth_str) = auth.to_str() {
if let Some(token) = auth_str.strip_prefix("Bearer ") {
return Some(token.to_string());
}
}
}
None
}
fn is_context_stats_path(path: &str) -> bool {
let trimmed = path.trim_start_matches('/').trim_end_matches('/');
let parts: Vec<&str> = trimmed.split('/').collect();
parts.len() == 3
&& parts.first().copied() == Some("contexts")
&& parts.get(2).copied() == Some("stats")
}
pub fn is_web_ui_allowlisted(path: &str) -> bool {
WEB_UI_ALLOWLIST.contains(&path) || is_context_stats_path(path)
}
pub async fn auth_middleware(
State(config): State<AuthConfig>,
mut request: Request,
next: Next,
) -> Response {
if config.web_ui_enabled
&& request.method() == axum::http::Method::GET
&& is_web_ui_allowlisted(request.uri().path())
{
return next.run(request).await;
}
let has_auth = config.api_key.is_some()
|| config
.agent_registry
.as_ref()
.is_some_and(|r| !r.is_empty());
if !has_auth {
return next.run(request).await;
}
let provided_key = match extract_api_key(&request) {
Some(key) => key,
None => {
let response = AuthErrorResponse {
error: "API key required (provide via X-API-Key or Authorization: Bearer header)",
};
return (StatusCode::UNAUTHORIZED, axum::Json(response)).into_response();
}
};
if let Some(ref registry) = config.agent_registry {
if !registry.is_empty() {
if let Some(lookup) = registry.lookup(&provided_key) {
if let Some(ref limiter) = lookup.rate_limiter {
if !limiter.try_acquire() {
lookup.quota.record_rate_limited();
let response = RateLimitResponse {
error: "Rate limit exceeded",
retry_after_ms: 100,
};
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(response))
.into_response();
}
}
lookup.quota.record_request();
request.extensions_mut().insert(lookup.identity);
return next.run(request).await;
}
}
}
match &config.api_key {
Some(expected_key) if constant_time_eq(&provided_key, expected_key) => {
next.run(request).await
}
Some(_) => {
let response = AuthErrorResponse {
error: "Invalid API key",
};
(StatusCode::UNAUTHORIZED, axum::Json(response)).into_response()
}
None => {
let response = AuthErrorResponse {
error: "Invalid API key",
};
(StatusCode::UNAUTHORIZED, axum::Json(response)).into_response()
}
}
}
#[derive(Clone)]
pub struct RateLimitConfig {
pub limiter: Option<Arc<RateLimiter>>,
}
impl RateLimitConfig {
pub fn with_limiter(limiter: Arc<RateLimiter>) -> Self {
Self {
limiter: Some(limiter),
}
}
pub fn disabled() -> Self {
Self { limiter: None }
}
}
pub async fn rate_limit_middleware(
State(config): State<RateLimitConfig>,
request: Request,
next: Next,
) -> Response {
let limiter = match &config.limiter {
Some(l) => l,
None => return next.run(request).await,
};
if request.extensions().get::<AgentIdentity>().is_some() {
return next.run(request).await;
}
if limiter.try_acquire() {
next.run(request).await
} else {
let response = RateLimitResponse {
error: "Rate limit exceeded",
retry_after_ms: 100, };
(StatusCode::TOO_MANY_REQUESTS, axum::Json(response)).into_response()
}
}
#[cfg(test)]
#[path = "tests/middleware_tests.rs"]
mod tests;