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