1#![allow(clippy::doc_markdown)]
9
10use std::collections::{HashMap, HashSet, VecDeque};
11use std::net::{IpAddr, SocketAddr};
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use axum::{
17 extract::{ConnectInfo, Request, State},
18 http::{header, HeaderValue, StatusCode},
19 middleware::{self, Next},
20 response::{IntoResponse, Response},
21 routing::get,
22 Json, Router,
23};
24use rand::RngExt as _;
25use rmcp::transport::streamable_http_server::{
26 session::local::LocalSessionManager, session::SessionManager as _, StreamableHttpServerConfig,
27 StreamableHttpService,
28};
29use serde::Serialize;
30use tokio::sync::{Mutex, Semaphore};
31use tower_http::timeout::TimeoutLayer;
32
33use crate::config::{HttpPrincipal, HttpSessionAffinity};
34use crate::runtime::{configured_shell_runtime, ShellRuntime};
35use crate::server::{SessionIsolation, WinxService};
36
37mod observability;
38use observability::{observe_request_entry, PermitBody};
39
40pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
41
42pub const MAX_BODY_BYTES: usize = 16 * 1024 * 1024;
48pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
50const RATE_WINDOW: Duration = Duration::from_secs(60);
51const MAX_RATE_TRACKED_IPS: usize = 4_096;
52const INVALID_AUTH_DELAY: Duration = Duration::from_millis(100);
53pub const DEFAULT_MAX_CONCURRENCY: usize = 32;
54pub const DEFAULT_REQUESTS_PER_MINUTE: u32 = 120;
58pub const DEFAULT_PRINCIPAL_REQUESTS_PER_MINUTE: u32 = 120;
62
63#[derive(Debug)]
64struct HealthState {
65 ready: AtomicBool,
66}
67
68#[derive(Serialize)]
69struct HealthResponse {
70 status: &'static str,
71 ready: bool,
72}
73
74#[derive(Clone, Debug)]
77pub(crate) struct RequestCorrelation(String);
78
79impl RequestCorrelation {
80 fn new() -> Self {
81 Self(format!("r_{:032x}", rand::rng().random::<u128>()))
82 }
83
84 pub(crate) fn as_str(&self) -> &str {
85 &self.0
86 }
87}
88
89#[derive(Clone, Debug)]
90pub struct HttpServerOptions {
91 pub bind: String,
92 pub principals: Vec<HttpPrincipal>,
93 pub extra_hosts: Vec<String>,
94 pub allow_query_token: bool,
95 pub allow_non_loopback: bool,
96 pub session_affinity: HttpSessionAffinity,
97 pub max_concurrency: usize,
98 pub requests_per_minute: u32,
100 pub principal_requests_per_minute: u32,
101}
102
103impl HttpServerOptions {
104 pub fn single(
105 bind: impl Into<String>,
106 token: impl Into<String>,
107 extra_hosts: Vec<String>,
108 allow_query_token: bool,
109 ) -> Result<Self, BoxError> {
110 let token = token.into();
111 let principal = HttpPrincipal::new("default", token.trim(), false)?;
112 Ok(Self {
113 bind: bind.into(),
114 principals: vec![principal],
115 extra_hosts,
116 allow_query_token,
117 allow_non_loopback: false,
118 session_affinity: HttpSessionAffinity::Workspace,
119 max_concurrency: DEFAULT_MAX_CONCURRENCY,
120 requests_per_minute: DEFAULT_REQUESTS_PER_MINUTE,
121 principal_requests_per_minute: DEFAULT_PRINCIPAL_REQUESTS_PER_MINUTE,
122 })
123 }
124}
125
126pub async fn start_http_server(
127 bind: &str,
128 token: String,
129 extra_hosts: Vec<String>,
130 allow_query_token: bool,
131) -> Result<(), BoxError> {
132 let options = HttpServerOptions::single(bind, token, extra_hosts, allow_query_token)?;
133 start_http_server_with_options(options).await
134}
135
136pub async fn start_http_server_with_options(options: HttpServerOptions) -> Result<(), BoxError> {
137 let shell_runtime = configured_shell_runtime().await?;
138 start_http_server_with_runtime(options, shell_runtime).await
139}
140
141pub async fn start_http_server_with_runtime(
142 options: HttpServerOptions,
143 shell_runtime: Arc<dyn ShellRuntime>,
144) -> Result<(), BoxError> {
145 let bind = validate_options(&options)?;
146 let principal_names =
147 options.principals.iter().map(|principal| principal.name().to_string()).collect::<Vec<_>>();
148 let allow_query_token = options.allow_query_token;
149 let health_state = Arc::new(HealthState { ready: AtomicBool::new(true) });
150 let app = build_app(options, shell_runtime, Arc::clone(&health_state));
151
152 let listener = tokio::net::TcpListener::bind(bind).await?;
153 tracing::warn!(
154 bind = %bind,
155 principals = ?principal_names,
156 route = "/mcp",
157 "Winx remote MCP is network-reachable; keep it behind authenticated HTTPS"
158 );
159 if allow_query_token {
160 tracing::warn!(
161 "query-token authentication is enabled; URLs may expose credentials in proxy logs/history"
162 );
163 }
164 axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
165 .with_graceful_shutdown(shutdown_signal(health_state))
166 .await?;
167 Ok(())
168}
169
170fn build_app(
172 options: HttpServerOptions,
173 shell_runtime: Arc<dyn ShellRuntime>,
174 health_state: Arc<HealthState>,
175) -> Router {
176 let shared_concurrency = Arc::new(Semaphore::new(options.max_concurrency));
177 let auth_failure_limiter = Arc::new(RateLimiter::new(options.requests_per_minute));
178 let principal_rate_limiter = Arc::new(RateLimiter::new(options.principal_requests_per_minute));
179
180 let mut config = StreamableHttpServerConfig::default();
181 config.legacy_session_mode = true;
182 config.allowed_hosts.extend(options.extra_hosts);
183 config.max_request_body_bytes = MAX_BODY_BYTES;
184
185 let shared = WinxService::with_runtime(SessionIsolation::Strict, shell_runtime);
186 let session_manager = Arc::new(LocalSessionManager::default());
187 let mcp_service =
188 StreamableHttpService::new(move || Ok(shared.clone()), session_manager.clone(), config);
189 let security = Arc::new(RouteSecurity {
190 principals: Arc::new(options.principals),
191 allow_query: options.allow_query_token,
192 session_affinity: options.session_affinity,
193 concurrency: shared_concurrency,
194 auth_failure_limiter,
195 principal_rate_limiter,
196 session_owners: Arc::new(SessionOwners::default()),
197 session_manager,
198 });
199 let protected_mcp = Router::new()
204 .nest_service("/mcp", mcp_service)
205 .layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, REQUEST_TIMEOUT))
206 .layer(middleware::from_fn_with_state(security, enforce_route_security))
207 .layer(middleware::from_fn(reject_declared_oversized_body));
208 let health_routes = Router::new()
212 .route("/healthz", get(healthz))
213 .route("/readyz", get(readyz))
214 .with_state(health_state);
215 Router::new().merge(health_routes).merge(protected_mcp)
216}
217
218async fn reject_declared_oversized_body(request: Request, next: Next) -> Response {
225 let declared = request
226 .headers()
227 .get(header::CONTENT_LENGTH)
228 .and_then(|value| value.to_str().ok())
229 .and_then(|value| value.trim().parse::<u64>().ok());
230 let limit = u64::try_from(MAX_BODY_BYTES).unwrap_or(u64::MAX);
231 if declared.is_some_and(|length| length > limit) {
232 return (
233 StatusCode::PAYLOAD_TOO_LARGE,
234 format!("Payload Too Large: request body exceeds {MAX_BODY_BYTES} bytes"),
235 )
236 .into_response();
237 }
238 next.run(request).await
239}
240
241async fn healthz() -> impl IntoResponse {
242 (StatusCode::OK, Json(HealthResponse { status: "ok", ready: true }))
243}
244
245async fn readyz(State(state): State<Arc<HealthState>>) -> impl IntoResponse {
246 let ready = state.ready.load(Ordering::Acquire);
247 let status = if ready { StatusCode::OK } else { StatusCode::SERVICE_UNAVAILABLE };
248 (status, Json(HealthResponse { status: if ready { "ready" } else { "draining" }, ready }))
249}
250
251async fn shutdown_signal(state: Arc<HealthState>) {
252 #[cfg(unix)]
253 {
254 let terminate = async {
255 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
256 Ok(mut signal) => {
257 signal.recv().await;
258 }
259 Err(error) => {
260 tracing::warn!(%error, "failed to install SIGTERM handler");
261 std::future::pending::<()>().await;
262 }
263 }
264 };
265 tokio::select! {
266 result = tokio::signal::ctrl_c() => {
267 if let Err(error) = result {
268 tracing::warn!(%error, "failed while waiting for Ctrl-C");
269 }
270 }
271 () = terminate => {}
272 }
273 }
274 #[cfg(not(unix))]
275 if let Err(error) = tokio::signal::ctrl_c().await {
276 tracing::warn!(%error, "failed while waiting for Ctrl-C");
277 }
278
279 state.ready.store(false, Ordering::Release);
280 tracing::info!("HTTP shutdown requested; draining in-flight requests");
281}
282
283fn validate_options(options: &HttpServerOptions) -> Result<SocketAddr, BoxError> {
284 let bind: SocketAddr = options
285 .bind
286 .parse()
287 .map_err(|error| format!("invalid --bind socket address {:?}: {error}", options.bind))?;
288 if !bind.ip().is_loopback() && !options.allow_non_loopback {
289 return Err(format!(
290 "refusing non-loopback HTTP bind {bind}; pass --allow-non-loopback only behind a trusted firewall/tunnel"
291 )
292 .into());
293 }
294 if options.max_concurrency == 0 {
295 return Err("max_concurrency must be at least 1".into());
296 }
297 if options.requests_per_minute == 0 {
298 return Err("requests_per_minute must be at least 1".into());
299 }
300 if options.principal_requests_per_minute == 0 {
301 return Err("principal_requests_per_minute must be at least 1".into());
302 }
303 if options.principals.is_empty() {
304 return Err("at least one HTTP principal is required".into());
305 }
306 let mut names = HashSet::new();
307 for principal in &options.principals {
308 if !names.insert(principal.name()) {
309 return Err(format!("duplicate HTTP principal: {}", principal.name()).into());
310 }
311 }
312 Ok(bind)
313}
314
315#[derive(Clone)]
316struct RouteSecurity {
317 principals: Arc<Vec<HttpPrincipal>>,
318 allow_query: bool,
319 session_affinity: HttpSessionAffinity,
320 concurrency: Arc<Semaphore>,
321 auth_failure_limiter: Arc<RateLimiter<IpAddr>>,
322 principal_rate_limiter: Arc<RateLimiter<String>>,
323 session_owners: Arc<SessionOwners>,
324 session_manager: Arc<LocalSessionManager>,
325}
326
327const MAX_TRACKED_SESSIONS: usize = 65_536;
331
332#[derive(Default)]
339struct SessionOwners {
340 inner: std::sync::Mutex<SessionOwnerTable>,
341}
342
343#[derive(Default)]
344struct SessionOwnerTable {
345 owners: HashMap<String, String>,
346 order: VecDeque<String>,
347}
348
349impl SessionOwners {
350 fn owner_matches(&self, session_id: &str, principal: &str) -> bool {
355 let table = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
356 table.owners.get(session_id).is_none_or(|owner| owner == principal)
357 }
358
359 fn record(&self, session_id: &str, principal: &str) -> Vec<String> {
363 let mut table = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
364 if table.owners.contains_key(session_id) {
365 return Vec::new();
366 }
367 table.owners.insert(session_id.to_string(), principal.to_string());
368 table.order.push_back(session_id.to_string());
369 let mut evicted = Vec::new();
370 while table.order.len() > MAX_TRACKED_SESSIONS {
371 if let Some(oldest) = table.order.pop_front() {
372 table.owners.remove(&oldest);
373 evicted.push(oldest);
374 }
375 }
376 evicted
377 }
378
379 fn forget(&self, session_id: &str) {
380 let mut table = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
381 if table.owners.remove(session_id).is_some() {
382 table.order.retain(|id| id != session_id);
383 }
384 }
385}
386
387fn session_id_header(headers: &header::HeaderMap) -> Option<String> {
388 headers
389 .get("mcp-session-id")
390 .and_then(|value| value.to_str().ok())
391 .map(str::trim)
392 .filter(|value| !value.is_empty())
393 .map(ToString::to_string)
394}
395
396async fn enforce_route_security(
397 State(security): State<Arc<RouteSecurity>>,
398 ConnectInfo(peer): ConnectInfo<SocketAddr>,
399 request: Request,
400 next: Next,
401) -> Response {
402 let path = request.uri().path();
403 if path != "/mcp" && !path.starts_with("/mcp/") {
404 return next.run(request).await;
405 }
406 let (started, method, correlation) = observe_request_entry(peer, request.method());
407 let Some(principal) =
412 authenticate_request(&request, &security.principals, security.allow_query)
413 else {
414 return reject_failed_auth(&security, peer, &correlation, request.uri().path()).await;
415 };
416
417 if principal.has_weak_token() && security.auth_failure_limiter.exhausted(&peer.ip()).await {
420 tokio::time::sleep(INVALID_AUTH_DELAY).await;
421 log_rejection(peer, None, &correlation, "ip_rate_limit", StatusCode::TOO_MANY_REQUESTS);
422 return retry_response(StatusCode::TOO_MANY_REQUESTS, "request rate limit exceeded\n");
423 }
424
425 let principal_name = principal.name().to_string();
426 if !security.principal_rate_limiter.allow(principal_name.clone()).await {
427 log_rejection(
428 peer,
429 Some(&principal_name),
430 &correlation,
431 "principal_rate_limit",
432 StatusCode::TOO_MANY_REQUESTS,
433 );
434 return retry_response(StatusCode::TOO_MANY_REQUESTS, "principal rate limit exceeded\n");
435 }
436 let presented_session = session_id_header(request.headers());
440 if let Some(session_id) = presented_session.as_deref() {
441 if !security.session_owners.owner_matches(session_id, &principal_name) {
442 tracing::warn!(
443 peer = %peer,
444 principal = %principal_name,
445 "rejected request presenting another principal's MCP session id"
446 );
447 log_rejection(
448 peer,
449 Some(&principal_name),
450 &correlation,
451 "foreign_session",
452 StatusCode::NOT_FOUND,
453 );
454 return (StatusCode::NOT_FOUND, "session not found\n").into_response();
455 }
456 }
457 let Ok(permit) = security.concurrency.clone().try_acquire_owned() else {
458 log_rejection(
459 peer,
460 Some(&principal_name),
461 &correlation,
462 "concurrency",
463 StatusCode::SERVICE_UNAVAILABLE,
464 );
465 return retry_response(StatusCode::SERVICE_UNAVAILABLE, "too many concurrent requests\n");
466 };
467 let mut request = request;
468 request.extensions_mut().insert(principal);
469 request.extensions_mut().insert(security.session_affinity);
470 request.extensions_mut().insert(correlation.clone());
471 let response = next.run(request).await;
472 if response.status().is_success() {
473 if let Some(minted) = session_id_header(response.headers()) {
474 for evicted in security.session_owners.record(&minted, &principal_name) {
475 let manager = security.session_manager.clone();
476 tokio::spawn(async move {
477 let id: rmcp::transport::common::server_side_http::SessionId =
478 Arc::from(evicted.as_str());
479 let _ = manager.close_session(&id).await;
480 });
481 }
482 }
483 if method == axum::http::Method::DELETE {
484 if let Some(session_id) = presented_session.as_deref() {
485 security.session_owners.forget(session_id);
486 }
487 }
488 }
489 let response_ready = Instant::now();
492 tracing::info!(
493 target: "winx::usage",
494 event = "http_request",
495 phase = "response_ready",
496 principal = %principal_name,
497 request_id = %correlation.as_str(),
498 peer = %peer,
499 method = %method,
500 status = response.status().as_u16(),
501 duration_ms = u64::try_from(response_ready.saturating_duration_since(started).as_millis())
502 .unwrap_or(u64::MAX),
503 "http request"
504 );
505 response.map(|body| {
508 axum::body::Body::new(PermitBody::new(body, permit).observe(
509 correlation,
510 started,
511 response_ready,
512 ))
513 })
514}
515
516async fn reject_failed_auth(
523 security: &RouteSecurity,
524 peer: SocketAddr,
525 correlation: &RequestCorrelation,
526 path: &str,
527) -> Response {
528 let within_budget = security.auth_failure_limiter.allow(peer.ip()).await;
529 tokio::time::sleep(INVALID_AUTH_DELAY).await;
530 if !within_budget {
531 log_rejection(peer, None, correlation, "ip_rate_limit", StatusCode::TOO_MANY_REQUESTS);
532 return retry_response(StatusCode::TOO_MANY_REQUESTS, "request rate limit exceeded\n");
533 }
534 tracing::warn!(peer = %peer, path = %path, "rejected request with missing or invalid HTTP token");
535 log_rejection(peer, None, correlation, "auth", StatusCode::UNAUTHORIZED);
536 (StatusCode::UNAUTHORIZED, "missing or invalid token\n").into_response()
537}
538
539fn log_rejection(
543 peer: SocketAddr,
544 principal: Option<&str>,
545 correlation: &RequestCorrelation,
546 reason: &str,
547 status: StatusCode,
548) {
549 tracing::info!(
550 target: "winx::usage",
551 event = "http_rejected",
552 request_id = %correlation.as_str(),
553 principal = principal.unwrap_or(""),
554 peer = %peer,
555 reason,
556 status = status.as_u16(),
557 "http request rejected"
558 );
559}
560fn retry_response(status: StatusCode, body: &'static str) -> Response {
561 let mut response = (status, body).into_response();
562 response.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from_static("1"));
563 response
564}
565
566#[derive(Debug)]
567struct RateWindow {
568 started_at: Instant,
569 last_seen: Instant,
570 count: u32,
571}
572
573#[derive(Debug)]
574struct RateLimiter<K> {
577 requests_per_window: u32,
578 windows: Mutex<HashMap<K, RateWindow>>,
579}
580
581impl<K: std::hash::Hash + Eq + Clone> RateLimiter<K> {
582 fn new(requests_per_window: u32) -> Self {
583 Self { requests_per_window, windows: Mutex::new(HashMap::new()) }
584 }
585
586 async fn allow(&self, peer: K) -> bool {
587 let now = Instant::now();
588 let mut windows = self.windows.lock().await;
589 windows.retain(|_, window| now.duration_since(window.last_seen) < RATE_WINDOW * 2);
590 if !windows.contains_key(&peer) && windows.len() >= MAX_RATE_TRACKED_IPS {
591 if let Some(oldest) =
592 windows.iter().min_by_key(|(_, window)| window.last_seen).map(|(ip, _)| ip.clone())
593 {
594 windows.remove(&oldest);
595 }
596 }
597 let window =
598 windows.entry(peer).or_insert(RateWindow { started_at: now, last_seen: now, count: 0 });
599 if now.duration_since(window.started_at) >= RATE_WINDOW {
600 window.started_at = now;
601 window.count = 0;
602 }
603 window.last_seen = now;
604 if window.count >= self.requests_per_window {
605 return false;
606 }
607 window.count = window.count.saturating_add(1);
608 true
609 }
610
611 async fn exhausted(&self, peer: &K) -> bool {
614 let windows = self.windows.lock().await;
615 windows.get(peer).is_some_and(|window| {
616 Instant::now().duration_since(window.started_at) < RATE_WINDOW
617 && window.count >= self.requests_per_window
618 })
619 }
620}
621
622fn authenticate_request(
623 request: &Request,
624 principals: &[HttpPrincipal],
625 allow_query: bool,
626) -> Option<HttpPrincipal> {
627 let header = bearer_token(request);
628 let query = allow_query.then(|| query_token(request)).flatten();
629 [header, query].into_iter().flatten().find_map(|presented| {
630 principals.iter().find(|principal| principal.matches_token(presented)).cloned()
631 })
632}
633
634fn bearer_token(request: &Request) -> Option<&str> {
635 let value = request.headers().get(header::AUTHORIZATION)?.to_str().ok()?;
636 let (scheme, token) = value.split_once(' ')?;
637 scheme.eq_ignore_ascii_case("bearer").then_some(token.trim())
638}
639
640fn query_token(request: &Request) -> Option<&str> {
641 request.uri().query()?.split('&').find_map(|pair| pair.strip_prefix("token="))
642}
643
644#[cfg(test)]
645mod admission_tests {
646 #![allow(clippy::expect_used)]
647 use std::sync::Arc;
648
649 use tokio::sync::Semaphore;
650
651 use super::{PermitBody, RateLimiter};
652
653 #[tokio::test]
654 async fn principal_windows_are_independent_of_each_other() {
655 let limiter: RateLimiter<String> = RateLimiter::new(2);
656 assert!(limiter.allow("left".to_string()).await);
657 assert!(limiter.allow("left".to_string()).await);
658 assert!(!limiter.allow("left".to_string()).await, "left exhausted its window");
659 assert!(limiter.allow("right".to_string()).await, "right is unaffected");
660 }
661
662 #[tokio::test]
663 async fn permit_is_released_only_when_the_body_finishes() {
664 let semaphore = Arc::new(Semaphore::new(1));
665 let permit = semaphore.clone().try_acquire_owned().expect("permit");
666 let body =
667 axum::body::Body::new(PermitBody::new(axum::body::Body::from("payload"), permit));
668 assert_eq!(semaphore.available_permits(), 0, "held while the body is pending");
669 let bytes = axum::body::to_bytes(body, usize::MAX).await.expect("read body");
670 assert_eq!(&bytes[..], b"payload");
671 assert_eq!(semaphore.available_permits(), 1, "released with the consumed body");
672
673 let permit = semaphore.clone().try_acquire_owned().expect("permit again");
674 let body =
675 axum::body::Body::new(PermitBody::new(axum::body::Body::from("abandoned"), permit));
676 drop(body);
677 assert_eq!(semaphore.available_permits(), 1, "released when a client disconnects");
678 }
679}
680
681#[cfg(test)]
682mod session_owner_tests {
683 use super::SessionOwners;
684
685 #[test]
686 fn minted_session_ids_belong_to_their_first_creator() {
687 let owners = SessionOwners::default();
688 assert!(owners.owner_matches("conv-a", "left"), "unminted ids are not SDK sessions");
689 assert!(owners.record("s1", "left").is_empty(), "nothing evicted");
690 assert!(owners.owner_matches("s1", "left"));
691 assert!(!owners.owner_matches("s1", "right"), "another principal");
692 assert!(owners.record("s1", "right").is_empty(), "nothing evicted");
693 assert!(owners.owner_matches("s1", "left"), "first creator wins");
694 owners.forget("s1");
695 assert!(owners.owner_matches("s1", "right"), "closed ids are plain unknown ids again");
696 }
697
698 #[test]
699 fn eviction_hands_back_the_ids_whose_sdk_sessions_must_close() {
700 let owners = SessionOwners::default();
701 assert!(owners.record("oldest", "left").is_empty(), "nothing evicted");
702 let mut evicted = Vec::new();
703 for index in 0..super::MAX_TRACKED_SESSIONS {
704 evicted.extend(owners.record(&format!("s{index}"), "left"));
705 }
706 assert_eq!(evicted, vec!["oldest".to_string()]);
707 assert!(owners.owner_matches(&format!("s{}", super::MAX_TRACKED_SESSIONS - 1), "left"));
708 }
709}
710
711#[cfg(test)]
712mod tests {
713 #![allow(clippy::expect_used)]
714
715 use std::net::{IpAddr, Ipv4Addr};
716 use std::sync::atomic::{AtomicBool, Ordering};
717 use std::sync::Arc;
718
719 use axum::body::Body;
720 use axum::extract::{Request, State};
721 use axum::http::StatusCode;
722 use axum::response::IntoResponse as _;
723
724 use super::{
725 authenticate_request, readyz, validate_options, HealthState, HttpServerOptions, RateLimiter,
726 };
727 use crate::config::HttpPrincipal;
728
729 fn request(uri: &str, authorization: Option<&str>) -> Request {
730 let mut builder = Request::builder().uri(uri);
731 if let Some(authorization) = authorization {
732 builder = builder.header(axum::http::header::AUTHORIZATION, authorization);
733 }
734 builder.body(Body::empty()).expect("test request")
735 }
736
737 fn principal(name: &str, token_byte: char) -> HttpPrincipal {
738 let token = token_byte.to_string().repeat(32);
739 HttpPrincipal::new(name, &token, false).expect("valid principal")
740 }
741
742 fn strong_options(bind: &str) -> HttpServerOptions {
743 HttpServerOptions {
744 bind: bind.to_string(),
745 principals: vec![principal("default", 'x')],
746 extra_hosts: Vec::new(),
747 allow_query_token: false,
748 allow_non_loopback: false,
749 session_affinity: crate::config::HttpSessionAffinity::Workspace,
750 max_concurrency: super::DEFAULT_MAX_CONCURRENCY,
751 requests_per_minute: super::DEFAULT_REQUESTS_PER_MINUTE,
752 principal_requests_per_minute: super::DEFAULT_PRINCIPAL_REQUESTS_PER_MINUTE,
753 }
754 }
755
756 #[test]
757 fn bearer_and_opt_in_query_tokens_select_the_matching_principal() {
758 let principals = vec![principal("chatgpt", 'a'), principal("automation", 'b')];
759 let header = request("/mcp", Some(&format!("Bearer {}", "a".repeat(32))));
760 let matched = authenticate_request(&header, &principals, false).expect("header principal");
761 assert_eq!(matched.name(), "chatgpt");
762
763 let query_uri = format!("/mcp?token={}", "b".repeat(32));
764 assert!(authenticate_request(&request(&query_uri, None), &principals, false).is_none());
765 let matched = authenticate_request(&request(&query_uri, None), &principals, true)
766 .expect("query principal");
767 assert_eq!(matched.name(), "automation");
768 }
769
770 #[test]
771 fn non_loopback_binding_requires_explicit_override() {
772 assert!(validate_options(&strong_options("127.0.0.1:8000")).is_ok());
773 assert!(validate_options(&strong_options("0.0.0.0:8000")).is_err());
774
775 let mut public = strong_options("0.0.0.0:8000");
776 public.allow_non_loopback = true;
777 assert!(validate_options(&public).is_ok());
778 }
779
780 #[tokio::test]
781 async fn rate_limiter_rejects_excess_requests() {
782 let limiter = RateLimiter::new(2);
783 let peer = IpAddr::V4(Ipv4Addr::LOCALHOST);
784 assert!(limiter.allow(peer).await);
785 assert!(limiter.allow(peer).await);
786 assert!(!limiter.allow(peer).await);
787 }
788
789 #[test]
790 fn duplicate_principal_names_are_rejected() {
791 let mut options = strong_options("127.0.0.1:8000");
792 options.principals = vec![principal("same", 'a'), principal("same", 'b')];
793 assert!(validate_options(&options).is_err());
794 }
795
796 #[tokio::test]
797 async fn readiness_turns_unavailable_while_draining() {
798 let state = Arc::new(HealthState { ready: AtomicBool::new(true) });
799 let ready = readyz(State(Arc::clone(&state))).await.into_response();
800 assert_eq!(ready.status(), StatusCode::OK);
801
802 state.ready.store(false, Ordering::Release);
803 let draining = readyz(State(state)).await.into_response();
804 assert_eq!(draining.status(), StatusCode::SERVICE_UNAVAILABLE);
805 }
806}
807
808#[cfg(test)]
809mod observability_tests;
810
811#[cfg(test)]
812mod security_tests;