1use crate::{body_transform::BodyTransformationMiddleware, config::ProxyConfig};
7use axum::{
8 extract::Request, http::StatusCode, middleware::Next, response::Response, routing::get, Router,
9};
10use serde::Serialize;
11use std::net::SocketAddr;
12use std::sync::Arc;
13use tokio::sync::RwLock;
14use tracing::{debug, error, info, warn};
15
16pub struct ProxyServer {
18 config: Arc<RwLock<ProxyConfig>>,
20 log_requests: bool,
22 log_responses: bool,
24 request_counter: Arc<RwLock<u64>>,
26 start_time: std::time::Instant,
28 total_response_time_ms: Arc<RwLock<u64>>,
30 error_counter: Arc<RwLock<u64>>,
32 conformance_tap: Option<crate::conformance::ConformanceTap>,
35}
36
37impl std::fmt::Debug for ProxyServer {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 f.debug_struct("ProxyServer")
40 .field("log_requests", &self.log_requests)
41 .field("log_responses", &self.log_responses)
42 .field("conformance_tap", &self.conformance_tap.as_ref().map(|_| "configured"))
43 .finish()
44 }
45}
46
47impl ProxyServer {
48 pub fn new(config: ProxyConfig, log_requests: bool, log_responses: bool) -> Self {
50 let want_validation = std::env::var("MOCKFORGE_PROXY_VALIDATE_CONFORMANCE")
53 .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
54 .unwrap_or(false);
55 let conformance_tap = if want_validation {
56 let strict = std::env::var("MOCKFORGE_PROXY_VALIDATE_CONFORMANCE_STRICT")
57 .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
58 .unwrap_or(false);
59 match std::env::var("MOCKFORGE_PROXY_SPEC") {
60 Ok(path) if !path.trim().is_empty() => {
61 match std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(
62 |raw| {
63 serde_json::from_str::<serde_json::Value>(&raw)
64 .or_else(|_| serde_yaml::from_str::<serde_json::Value>(&raw))
65 .map_err(|e| e.to_string())
66 },
67 ) {
68 Ok(spec_value) => {
69 match crate::conformance::ConformanceTap::from_spec_value(
70 spec_value, strict,
71 ) {
72 Ok(tap) => {
73 info!(
74 spec = %path,
75 strict,
76 "proxy conformance validation enabled (#864)"
77 );
78 Some(tap)
79 }
80 Err(e) => {
81 warn!(spec = %path, error = %e, "conformance tap disabled");
82 None
83 }
84 }
85 }
86 Err(e) => {
87 warn!(spec = %path, error = %e, "cannot parse conformance spec; tap disabled");
88 None
89 }
90 }
91 }
92 _ => {
93 warn!("MOCKFORGE_PROXY_VALIDATE_CONFORMANCE set but MOCKFORGE_PROXY_SPEC missing; tap disabled");
94 None
95 }
96 }
97 } else {
98 None
99 };
100 Self {
101 config: Arc::new(RwLock::new(config)),
102 log_requests,
103 log_responses,
104 request_counter: Arc::new(RwLock::new(0)),
105 start_time: std::time::Instant::now(),
106 total_response_time_ms: Arc::new(RwLock::new(0)),
107 error_counter: Arc::new(RwLock::new(0)),
108 conformance_tap,
109 }
110 }
111
112 pub fn router(self) -> Router {
114 let state = Arc::new(self);
115 let state_for_middleware = state.clone();
116
117 Router::new()
118 .route("/proxy/health", get(health_check))
120 .fallback(proxy_handler)
122 .with_state(state)
123 .layer(axum::middleware::from_fn_with_state(state_for_middleware, logging_middleware))
124 }
125}
126
127async fn health_check() -> Result<Response<String>, StatusCode> {
129 Response::builder()
131 .status(StatusCode::OK)
132 .header("Content-Type", "application/json")
133 .body(r#"{"status":"healthy","service":"mockforge-proxy"}"#.to_string())
134 .map_err(|e| {
135 tracing::error!("Failed to build health check response: {}", e);
136 StatusCode::INTERNAL_SERVER_ERROR
137 })
138}
139
140async fn proxy_handler(
142 axum::extract::State(state): axum::extract::State<Arc<ProxyServer>>,
143 request: http::Request<axum::body::Body>,
144) -> Result<Response<String>, StatusCode> {
145 let client_addr = request
147 .extensions()
148 .get::<SocketAddr>()
149 .copied()
150 .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 0)));
151
152 let method = request.method().clone();
153 let uri = request.uri().clone();
154 let headers = request.headers().clone();
155
156 let body_bytes = match axum::body::to_bytes(request.into_body(), usize::MAX).await {
158 Ok(bytes) => Some(bytes.to_vec()),
159 Err(e) => {
160 error!("Failed to read request body: {}", e);
161 None
162 }
163 };
164
165 let config = state.config.read().await;
166
167 if !config.enabled {
169 return Err(StatusCode::SERVICE_UNAVAILABLE);
170 }
171
172 if !config.should_proxy_with_condition(&method, &uri, &headers, body_bytes.as_deref()) {
174 return Err(StatusCode::NOT_FOUND);
175 }
176
177 let stripped_path = config.strip_prefix(uri.path());
179
180 let base_upstream_url = config.get_upstream_url(uri.path());
182 let candidate = stripped_path.trim_start_matches('/');
185 let is_absolute_upstream =
186 candidate.starts_with("http://") || candidate.starts_with("https://");
187
188 if is_absolute_upstream && !config.allow_absolute_url_upstream {
193 warn!(
194 path = %uri.path(),
195 "Rejected absolute-URL-in-path proxying; set allow_absolute_url_upstream=true to opt in (#1012)"
196 );
197 let body =
198 "Forbidden: absolute-URL proxying is disabled (allow_absolute_url_upstream=false)"
199 .to_string();
200 return Ok(Response::builder()
201 .status(StatusCode::FORBIDDEN)
202 .body(body)
203 .unwrap_or_else(|_| Response::new(String::new())));
204 }
205
206 let full_upstream_url = if is_absolute_upstream {
207 use crate::egress::{EgressDecision, EgressGuard};
208 let guard = EgressGuard::new(config.upstream_allowlist.clone());
209 match guard.check(candidate).await {
210 EgressDecision::Allowed => candidate.to_string(),
211 EgressDecision::Blocked(reason) => {
212 warn!(target = %stripped_path, %reason, "Blocked request-derived upstream by egress guard");
213 let body = format!("Forbidden: upstream blocked by egress guard ({})", reason);
214 return Ok(Response::builder()
215 .status(StatusCode::FORBIDDEN)
216 .body(body)
217 .unwrap_or_else(|_| Response::new(String::new())));
218 }
219 }
220 } else {
221 let base = base_upstream_url.trim_end_matches('/');
222 let path = stripped_path.trim_start_matches('/');
223 let query = uri.query().map(|q| format!("?{}", q)).unwrap_or_default();
224 if path.is_empty() || path == "/" {
225 format!("{}{}", base, query)
226 } else {
227 format!("{}/{}", base, path) + &query
228 }
229 };
230
231 let _modified_uri = full_upstream_url.parse::<http::Uri>().unwrap_or_else(|_| uri.clone());
233
234 if state.log_requests {
236 let mut counter = state.request_counter.write().await;
237 *counter += 1;
238 let request_id = *counter;
239
240 info!(
241 request_id = request_id,
242 method = %method,
243 path = %uri.path(),
244 upstream = %full_upstream_url,
245 client_ip = %client_addr.ip(),
246 "Proxy request intercepted"
247 );
248 }
249
250 let mut header_map = std::collections::HashMap::new();
252 for (key, value) in &headers {
253 if let Ok(value_str) = value.to_str() {
254 header_map.insert(key.to_string(), value_str.to_string());
255 }
256 }
257
258 use crate::client::ProxyClient;
260 let proxy_client = ProxyClient::new();
261
262 let reqwest_method = match method.as_str() {
264 "GET" => reqwest::Method::GET,
265 "POST" => reqwest::Method::POST,
266 "PUT" => reqwest::Method::PUT,
267 "DELETE" => reqwest::Method::DELETE,
268 "HEAD" => reqwest::Method::HEAD,
269 "OPTIONS" => reqwest::Method::OPTIONS,
270 "PATCH" => reqwest::Method::PATCH,
271 _ => {
272 error!("Unsupported HTTP method: {}", method);
273 return Err(StatusCode::METHOD_NOT_ALLOWED);
274 }
275 };
276
277 for (key, value) in &config.headers {
279 header_map.insert(key.clone(), value.clone());
280 }
281
282 let mut transformed_request_body = body_bytes.clone();
284 if !config.request_replacements.is_empty() {
285 let transform_middleware = BodyTransformationMiddleware::new(
286 config.request_replacements.clone(),
287 Vec::new(), );
289 if let Err(e) =
290 transform_middleware.transform_request_body(uri.path(), &mut transformed_request_body)
291 {
292 warn!("Failed to transform request body: {}", e);
293 }
295 }
296
297 if let Some(tap) = &state.conformance_tap {
302 if let Some((status, payload)) = tap
303 .validate_request(
304 method.as_str(),
305 uri.path(),
306 uri.query(),
307 &headers,
308 Some(transformed_request_body.as_deref().unwrap_or(&[])),
309 )
310 .await
311 {
312 warn!(status = status, path = %uri.path(), "strict conformance rejection");
313 return Response::builder()
314 .status(status)
315 .body(payload.to_string())
316 .map_err(|_| StatusCode::BAD_GATEWAY);
317 }
318 }
319
320 match proxy_client
321 .send_request(
322 reqwest_method,
323 &full_upstream_url,
324 &header_map,
325 transformed_request_body.as_deref(),
326 )
327 .await
328 {
329 Ok(response) => {
330 let status = StatusCode::from_u16(response.status().as_u16())
331 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
332
333 if state.log_responses {
335 info!(
336 method = %method,
337 path = %uri.path(),
338 status = status.as_u16(),
339 "Proxy response sent"
340 );
341 }
342
343 let mut response_headers = http::HeaderMap::new();
345 for (name, value) in response.headers() {
346 if let (Ok(header_name), Ok(header_value)) = (
347 http::HeaderName::try_from(name.as_str()),
348 http::HeaderValue::try_from(value.as_bytes()),
349 ) {
350 response_headers.insert(header_name, header_value);
351 }
352 }
353
354 let response_body_bytes = response.bytes().await.map_err(|e| {
356 error!("Failed to read proxy response body: {}", e);
357 StatusCode::BAD_GATEWAY
358 })?;
359
360 let mut final_body_bytes = response_body_bytes.to_vec();
362 {
363 let config_for_response = state.config.read().await;
364 if !config_for_response.response_replacements.is_empty() {
365 let transform_middleware = BodyTransformationMiddleware::new(
366 Vec::new(), config_for_response.response_replacements.clone(),
368 );
369 let mut body_option = Some(final_body_bytes.clone());
370 if let Err(e) = transform_middleware.transform_response_body(
371 uri.path(),
372 status.as_u16(),
373 &mut body_option,
374 ) {
375 warn!("Failed to transform response body: {}", e);
376 } else if let Some(transformed_body) = body_option {
378 final_body_bytes = transformed_body;
379 }
380 }
381 }
382
383 if let Some(tap) = &state.conformance_tap {
385 tap.validate_response(
386 method.as_str(),
387 uri.path(),
388 status.as_u16(),
389 Some(final_body_bytes.as_slice()),
390 )
391 .await;
392 }
393
394 let body_string = String::from_utf8_lossy(&final_body_bytes).to_string();
395
396 let mut response_builder = Response::builder().status(status);
398 for (name, value) in response_headers.iter() {
399 response_builder = response_builder.header(name, value);
400 }
401
402 response_builder
403 .body(body_string)
404 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
405 }
406 Err(e) => {
407 error!("Proxy request failed: {}", e);
408 Err(StatusCode::BAD_GATEWAY)
409 }
410 }
411}
412
413async fn logging_middleware(
415 axum::extract::State(state): axum::extract::State<Arc<ProxyServer>>,
416 request: Request,
417 next: Next,
418) -> Response {
419 let start = std::time::Instant::now();
420 let method = request.method().clone();
421 let uri = request.uri().clone();
422
423 let client_addr = request
425 .extensions()
426 .get::<SocketAddr>()
427 .copied()
428 .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 0)));
429
430 debug!(
431 method = %method,
432 uri = %uri,
433 client_ip = %client_addr.ip(),
434 "Request received"
435 );
436
437 let response = next.run(request).await;
438 let duration = start.elapsed();
439
440 {
442 let mut total_time = state.total_response_time_ms.write().await;
443 *total_time += duration.as_millis() as u64;
444 }
445
446 if response.status().is_server_error() {
448 let mut errors = state.error_counter.write().await;
449 *errors += 1;
450 }
451
452 debug!(
453 method = %method,
454 uri = %uri,
455 status = %response.status(),
456 duration_ms = duration.as_millis(),
457 "Response sent"
458 );
459
460 response
461}
462
463#[derive(Debug, Serialize)]
465pub struct ProxyStats {
466 pub total_requests: u64,
468 pub requests_per_second: f64,
470 pub avg_response_time_ms: f64,
472 pub error_rate_percent: f64,
474}
475
476pub async fn get_proxy_stats(state: &ProxyServer) -> ProxyStats {
478 let total_requests = *state.request_counter.read().await;
479 let total_response_time_ms = *state.total_response_time_ms.read().await;
480 let error_count = *state.error_counter.read().await;
481
482 let elapsed_secs = state.start_time.elapsed().as_secs_f64();
483 let requests_per_second = if elapsed_secs > 0.0 {
484 total_requests as f64 / elapsed_secs
485 } else {
486 0.0
487 };
488
489 let avg_response_time_ms = if total_requests > 0 {
490 total_response_time_ms as f64 / total_requests as f64
491 } else {
492 0.0
493 };
494
495 let error_rate_percent = if total_requests > 0 {
496 (error_count as f64 / total_requests as f64) * 100.0
497 } else {
498 0.0
499 };
500
501 ProxyStats {
502 total_requests,
503 requests_per_second,
504 avg_response_time_ms,
505 error_rate_percent,
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use crate::config::ProxyConfig;
513 use axum::http::StatusCode;
514
515 #[tokio::test]
516 async fn test_proxy_server_creation() {
517 let config = ProxyConfig::default();
518 let server = ProxyServer::new(config, true, true);
519
520 assert!(server.log_requests);
522 assert!(server.log_responses);
523 }
524
525 #[tokio::test]
526 async fn test_absolute_url_in_path_rejected_by_default() {
527 let config = ProxyConfig {
528 enabled: true,
529 prefix: Some("/proxy/".to_string()),
530 ..Default::default()
531 };
532 let server = Arc::new(ProxyServer::new(config, false, false));
534
535 let request = http::Request::builder()
536 .method("GET")
537 .uri("/proxy/http://169.254.169.254/latest/meta-data/")
538 .body(axum::body::Body::empty())
539 .unwrap();
540 let response = proxy_handler(axum::extract::State(server), request).await.unwrap();
541
542 assert_eq!(response.status(), StatusCode::FORBIDDEN);
543 }
544
545 #[tokio::test]
546 async fn test_opt_in_absolute_url_still_blocked_for_metadata_ip() {
547 let config = ProxyConfig {
548 enabled: true,
549 prefix: Some("/proxy/".to_string()),
550 allow_absolute_url_upstream: true,
551 ..Default::default()
552 };
553 let server = Arc::new(ProxyServer::new(config, false, false));
554
555 let request = http::Request::builder()
556 .method("GET")
557 .uri("/proxy/http://169.254.169.254/latest/meta-data/")
558 .body(axum::body::Body::empty())
559 .unwrap();
560 let response = proxy_handler(axum::extract::State(server), request).await.unwrap();
561
562 assert_eq!(response.status(), StatusCode::FORBIDDEN);
563 assert!(response.into_body().contains("egress guard"));
564 }
565
566 #[tokio::test]
567 async fn test_opt_in_absolute_url_blocked_for_loopback() {
568 let config = ProxyConfig {
569 enabled: true,
570 prefix: Some("/proxy/".to_string()),
571 allow_absolute_url_upstream: true,
572 ..Default::default()
573 };
574 let server = Arc::new(ProxyServer::new(config, false, false));
575
576 let request = http::Request::builder()
577 .method("GET")
578 .uri("/proxy/http://127.0.0.1:9090/admin")
579 .body(axum::body::Body::empty())
580 .unwrap();
581 let response = proxy_handler(axum::extract::State(server), request).await.unwrap();
582
583 assert_eq!(response.status(), StatusCode::FORBIDDEN);
584 }
585
586 #[tokio::test]
587 async fn test_allowlisted_private_host_bypasses_guard() {
588 use crate::egress::{EgressDecision, EgressGuard, UpstreamAllowlist};
589
590 let allowlist = UpstreamAllowlist {
592 url_prefixes: vec!["http://internal.local".to_string()],
593 hosts: Vec::new(),
594 };
595 let guard = EgressGuard::new(Some(allowlist));
596 match guard.check_without_dns("http://internal.local/v1") {
597 EgressDecision::Allowed => {}
598 EgressDecision::Blocked(e) => panic!("allowlisted host blocked: {}", e),
599 }
600 }
601
602 #[tokio::test]
603 async fn test_health_check() {
604 let response = health_check().await.unwrap();
605 assert_eq!(response.status(), StatusCode::OK);
606
607 let body = response.into_body();
609
610 assert!(body.contains("healthy"));
611 assert!(body.contains("mockforge-proxy"));
612 }
613
614 #[tokio::test]
615 async fn test_proxy_stats() {
616 let config = ProxyConfig::default();
617 let server = ProxyServer::new(config, false, false);
618
619 let stats = get_proxy_stats(&server).await;
621 assert_eq!(stats.total_requests, 0);
622 assert_eq!(stats.avg_response_time_ms, 0.0);
623 assert_eq!(stats.error_rate_percent, 0.0);
624 }
625}