Skip to main content

mockforge_http/middleware/
conn_diagnostics.rs

1//! Connection diagnostics middleware.
2//!
3//! Logs the HTTP version and `Connection` header value MockForge sees on every
4//! incoming request — the exact information needed to debug "why does
5//! MockForge close the connection after each response?" scenarios.
6//!
7//! Issue #79 — Srikanth's round-5 reply: his PCAP showed HTTP/1.1 from the
8//! proxy with no `Connection` header, but MockForge was sending FIN after each
9//! response. The only way to confirm what MockForge actually sees on the wire
10//! is to log the version + headers from hyper's parsed view of the request.
11//!
12//! Enabled by `MOCKFORGE_HTTP_LOG_CONN=1` (and convenience aliases
13//! `true|yes|on`). Off by default — the per-request log line is too noisy for
14//! normal operation.
15//!
16//! The emitted log uses INFO level so it surfaces under the default subscriber
17//! filter; the env var is the on/off switch.
18
19use axum::{body::Body, extract::ConnectInfo, http::Request, middleware::Next, response::Response};
20use std::net::SocketAddr;
21
22/// Is the connection-diagnostics log enabled? Reads
23/// `MOCKFORGE_HTTP_LOG_CONN` once per call (cheap — `env::var` is a hash
24/// lookup; we don't bother caching).
25pub fn is_conn_log_enabled() -> bool {
26    std::env::var("MOCKFORGE_HTTP_LOG_CONN")
27        .ok()
28        .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
29        .unwrap_or(false)
30}
31
32/// Render `hyper::Version` as a stable string (`HTTP/1.0`, `HTTP/1.1`,
33/// `HTTP/2.0`, `HTTP/3.0`, …) for log output. The `Debug` impl already
34/// produces this shape, but `{:?}` is documented as "not stable", so we
35/// match explicitly for the versions we care about and fall back to Debug
36/// for anything new.
37fn version_str(v: http::Version) -> String {
38    match v {
39        http::Version::HTTP_09 => "HTTP/0.9".to_string(),
40        http::Version::HTTP_10 => "HTTP/1.0".to_string(),
41        http::Version::HTTP_11 => "HTTP/1.1".to_string(),
42        http::Version::HTTP_2 => "HTTP/2.0".to_string(),
43        http::Version::HTTP_3 => "HTTP/3.0".to_string(),
44        other => format!("{:?}", other),
45    }
46}
47
48/// Middleware: when enabled, log the request's HTTP version + `Connection`
49/// header + selected hop-by-hop headers, then the response's `Connection`
50/// header (which is what determines whether hyper will FIN the socket).
51///
52/// Output (one line per request, tracing target = `mockforge_http::conn_diag`):
53///
54/// ```text
55/// http_conn_diag method=GET path=/v1.0/users version=HTTP/1.1 \
56///   req_connection="keep-alive" req_keep_alive="timeout=120" \
57///   req_host="192.168.2.86" peer=172.18.0.248:54321 \
58///   resp_status=200 resp_connection="keep-alive" \
59///   close_decision="keep-alive (HTTP/1.1, no Connection: close)"
60/// ```
61pub async fn conn_diag_middleware(req: Request<Body>, next: Next) -> Response<Body> {
62    if !is_conn_log_enabled() {
63        return next.run(req).await;
64    }
65
66    let method = req.method().clone();
67    let path = req.uri().path().to_string();
68    let version = req.version();
69    let version_label = version_str(version);
70
71    let req_connection = req
72        .headers()
73        .get(http::header::CONNECTION)
74        .and_then(|v| v.to_str().ok())
75        .unwrap_or("<absent>")
76        .to_string();
77    let req_keep_alive = req
78        .headers()
79        .get("keep-alive")
80        .and_then(|v| v.to_str().ok())
81        .unwrap_or("<absent>")
82        .to_string();
83    let req_host = req
84        .headers()
85        .get(http::header::HOST)
86        .and_then(|v| v.to_str().ok())
87        .unwrap_or("<absent>")
88        .to_string();
89    let req_te = req
90        .headers()
91        .get(http::header::TE)
92        .and_then(|v| v.to_str().ok())
93        .unwrap_or("<absent>")
94        .to_string();
95    let req_upgrade = req
96        .headers()
97        .get(http::header::UPGRADE)
98        .and_then(|v| v.to_str().ok())
99        .unwrap_or("<absent>")
100        .to_string();
101    let peer = req
102        .extensions()
103        .get::<ConnectInfo<SocketAddr>>()
104        .map(|c| c.0.to_string())
105        .unwrap_or_else(|| "<unknown>".to_string());
106
107    // Pre-compute the close decision hyper will make once the response goes
108    // back: HTTP/1.0 without `Connection: keep-alive` → close; HTTP/1.1 with
109    // `Connection: close` → close; everything else → keep-alive.
110    let close_decision = match version {
111        http::Version::HTTP_10 => {
112            if req_connection.to_ascii_lowercase().contains("keep-alive") {
113                "keep-alive (HTTP/1.0, explicit Connection: keep-alive)"
114            } else {
115                "close (HTTP/1.0 default — no Connection: keep-alive header)"
116            }
117        }
118        http::Version::HTTP_11 => {
119            if req_connection.to_ascii_lowercase().contains("close") {
120                "close (HTTP/1.1 with Connection: close)"
121            } else {
122                "keep-alive (HTTP/1.1 default — no Connection: close)"
123            }
124        }
125        _ => "n/a (HTTP/2+)",
126    };
127
128    let response = next.run(req).await;
129    let resp_status = response.status().as_u16();
130    let resp_connection = response
131        .headers()
132        .get(http::header::CONNECTION)
133        .and_then(|v| v.to_str().ok())
134        .unwrap_or("<absent>")
135        .to_string();
136    let resp_keep_alive = response
137        .headers()
138        .get("keep-alive")
139        .and_then(|v| v.to_str().ok())
140        .unwrap_or("<absent>")
141        .to_string();
142
143    tracing::info!(
144        target: "mockforge_http::conn_diag",
145        method = %method,
146        path = %path,
147        version = %version_label,
148        req_connection = %req_connection,
149        req_keep_alive = %req_keep_alive,
150        req_host = %req_host,
151        req_te = %req_te,
152        req_upgrade = %req_upgrade,
153        peer = %peer,
154        resp_status = resp_status,
155        resp_connection = %resp_connection,
156        resp_keep_alive = %resp_keep_alive,
157        close_decision = %close_decision,
158        "http_conn_diag",
159    );
160
161    response
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use axum::{routing::get, Router};
168    use http::HeaderValue;
169    use tower::ServiceExt;
170
171    fn isolate_env<F: FnOnce()>(value: Option<&str>, body: F) {
172        // Tests can't run in true parallel for env-var coverage; use a process-
173        // wide mutex held in the suite to serialize. Here we just save + set.
174        let prev = std::env::var("MOCKFORGE_HTTP_LOG_CONN").ok();
175        match value {
176            Some(v) => std::env::set_var("MOCKFORGE_HTTP_LOG_CONN", v),
177            None => std::env::remove_var("MOCKFORGE_HTTP_LOG_CONN"),
178        }
179        body();
180        match prev {
181            Some(p) => std::env::set_var("MOCKFORGE_HTTP_LOG_CONN", p),
182            None => std::env::remove_var("MOCKFORGE_HTTP_LOG_CONN"),
183        }
184    }
185
186    #[test]
187    fn enabled_flag_truthy_values() {
188        isolate_env(Some("1"), || assert!(is_conn_log_enabled()));
189        isolate_env(Some("true"), || assert!(is_conn_log_enabled()));
190        isolate_env(Some("on"), || assert!(is_conn_log_enabled()));
191        isolate_env(Some("yes"), || assert!(is_conn_log_enabled()));
192        isolate_env(Some("0"), || assert!(!is_conn_log_enabled()));
193        isolate_env(None, || assert!(!is_conn_log_enabled()));
194    }
195
196    #[tokio::test]
197    async fn middleware_is_transparent_when_disabled() {
198        isolate_env(None, || {});
199        let app: Router = Router::new()
200            .route("/", get(|| async { "ok" }))
201            .layer(axum::middleware::from_fn(conn_diag_middleware));
202
203        let req = Request::builder().uri("/").body(Body::empty()).unwrap();
204        let res = app.oneshot(req).await.unwrap();
205        assert_eq!(res.status(), 200);
206    }
207
208    #[tokio::test]
209    async fn middleware_passes_through_when_enabled() {
210        // Just confirm we don't drop the response. The actual log assertion
211        // is covered by inspecting tracing in higher-level integration tests.
212        let prev = std::env::var("MOCKFORGE_HTTP_LOG_CONN").ok();
213        std::env::set_var("MOCKFORGE_HTTP_LOG_CONN", "1");
214
215        let app: Router = Router::new()
216            .route("/x", get(|| async { "ok" }))
217            .layer(axum::middleware::from_fn(conn_diag_middleware));
218
219        let req = Request::builder()
220            .uri("/x")
221            .header(http::header::CONNECTION, HeaderValue::from_static("keep-alive"))
222            .body(Body::empty())
223            .unwrap();
224        let res = app.oneshot(req).await.unwrap();
225        assert_eq!(res.status(), 200);
226
227        match prev {
228            Some(p) => std::env::set_var("MOCKFORGE_HTTP_LOG_CONN", p),
229            None => std::env::remove_var("MOCKFORGE_HTTP_LOG_CONN"),
230        }
231    }
232
233    #[test]
234    fn version_str_renders_known_versions() {
235        assert_eq!(version_str(http::Version::HTTP_10), "HTTP/1.0");
236        assert_eq!(version_str(http::Version::HTTP_11), "HTTP/1.1");
237        assert_eq!(version_str(http::Version::HTTP_2), "HTTP/2.0");
238    }
239}