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