wowlab_sentinel/http/
mod.rs1mod api_error;
2pub(crate) mod auth;
3mod routes;
4mod services;
5
6use std::sync::Arc;
7
8use axum::{
9 extract::{Request, State},
10 http::{HeaderValue, Method, header},
11 middleware::Next,
12 response::{IntoResponse, Response},
13};
14use serde::Serialize;
15use tokio_util::sync::CancellationToken;
16use tower_http::{
17 cors::{AllowOrigin, CorsLayer},
18 trace::TraceLayer,
19};
20
21use crate::state::ServerState;
22
23crate::db_client!(HttpDb, "http", max = 10);
24
25#[derive(Debug)]
26pub(crate) struct PrettyJson<T>(pub(crate) T);
27
28impl<T> IntoResponse for PrettyJson<T>
29where
30 T: Serialize,
31{
32 fn into_response(self) -> Response {
33 let body = serde_json::to_string_pretty(&self.0).expect("serializable response type");
34
35 ([(header::CONTENT_TYPE, "application/json")], body).into_response()
36 }
37}
38
39pub(crate) async fn run(
40 state: Arc<ServerState>,
41 shutdown: CancellationToken,
42) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
43 let port = state.config.http_port;
44 let patterns: Vec<String> = state.config.cors_origins.clone();
45 let cors = CorsLayer::new()
46 .allow_origin(AllowOrigin::predicate(move |origin: &HeaderValue, _| {
47 let Ok(origin) = origin.to_str() else {
48 return false;
49 };
50
51 patterns.iter().any(|p| origin_matches(origin, p))
52 }))
53 .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
54 .allow_headers(cors_allowed_headers());
55
56 let app = routes::router(&state)
57 .layer(cors)
58 .layer(TraceLayer::new_for_http())
59 .with_state(state);
60
61 let listener = tokio::net::TcpListener::bind(format!("[::]:{port}")).await?;
62
63 tracing::info!(port, "HTTP server listening");
64
65 axum::serve(listener, app)
66 .with_graceful_shutdown(async move { shutdown.cancelled().await })
67 .await?;
68
69 Ok(())
70}
71
72pub(crate) async fn validate_mcp_origin(
73 State(state): State<Arc<ServerState>>,
74 request: Request,
75 next: Next,
76) -> Response {
77 match validate_origin(
78 request.headers().get(header::ORIGIN),
79 &state.config.cors_origins,
80 ) {
81 Ok(()) => next.run(request).await,
82 Err(status) => status.into_response(),
83 }
84}
85
86fn validate_origin(
87 origin: Option<&HeaderValue>,
88 allowed_patterns: &[String],
89) -> Result<(), axum::http::StatusCode> {
90 let Some(origin) = origin else {
91 return Ok(());
92 };
93 let Ok(origin) = origin.to_str() else {
94 return Err(axum::http::StatusCode::FORBIDDEN);
95 };
96
97 allowed_patterns
98 .iter()
99 .any(|pattern| origin_matches(origin, pattern))
100 .then_some(())
101 .ok_or(axum::http::StatusCode::FORBIDDEN)
102}
103
104fn origin_matches(origin: &str, pattern: &str) -> bool {
105 if !pattern.contains('*') {
106 return origin == pattern;
107 }
108
109 let parts: Vec<&str> = pattern.split('*').collect();
110
111 if parts.len() != 2 {
113 return origin == pattern;
114 }
115
116 origin.starts_with(parts[0]) && origin.ends_with(parts[1])
117}
118
119fn cors_allowed_headers() -> Vec<header::HeaderName> {
120 vec![
121 header::ACCEPT,
122 header::AUTHORIZATION,
123 header::CONTENT_TYPE,
124 header::HeaderName::from_static("mcp-method"),
125 header::HeaderName::from_static("mcp-name"),
126 header::HeaderName::from_static("mcp-protocol-version"),
127 header::HeaderName::from_static("x-node-key"),
128 header::HeaderName::from_static("x-node-sig"),
129 header::HeaderName::from_static("x-node-ts"),
130 ]
131}
132
133#[cfg(test)]
134mod tests {
135 use googletest::prelude::*;
136
137 use super::*;
138
139 #[gtest]
140 fn mcp_origins_are_matched_against_configured_patterns() {
141 assert!(origin_matches(
142 "http://localhost:4317",
143 "http://localhost:*"
144 ));
145 assert!(origin_matches("https://wowlab.gg", "https://wowlab.gg"));
146 assert!(!origin_matches(
147 "https://attacker.example",
148 "https://wowlab.gg"
149 ));
150 }
151
152 #[gtest]
153 fn mcp_origin_policy_rejects_untrusted_and_malformed_origins() -> Result<()> {
154 let patterns = vec!["https://wowlab.gg".to_owned()];
155 let malformed = HeaderValue::from_bytes(&[0xff])?;
156
157 verify_that!(validate_origin(None, &patterns), ok(anything()))?;
158 verify_that!(
159 validate_origin(
160 Some(&HeaderValue::from_static("https://wowlab.gg")),
161 &patterns
162 ),
163 ok(anything())
164 )?;
165 verify_that!(
166 validate_origin(
167 Some(&HeaderValue::from_static("https://attacker.example")),
168 &patterns
169 ),
170 err(eq(axum::http::StatusCode::FORBIDDEN))
171 )?;
172
173 verify_that!(
174 validate_origin(Some(&malformed), &patterns),
175 err(eq(axum::http::StatusCode::FORBIDDEN))
176 )
177 }
178
179 #[gtest]
180 fn cors_allows_modern_mcp_headers() {
181 let headers = cors_allowed_headers();
182
183 for required in [
184 header::AUTHORIZATION,
185 header::HeaderName::from_static("mcp-protocol-version"),
186 header::HeaderName::from_static("mcp-method"),
187 header::HeaderName::from_static("mcp-name"),
188 ] {
189 assert!(headers.contains(&required), "missing {required}");
190 }
191 }
192}