Tools: A Minimal HTTP Debug Server in Rust with axum (2026)

Tools: A Minimal HTTP Debug Server in Rust with axum (2026)

A Minimal HTTP Debug Server in Rust with axum

The problem with httpbin.org

Why axum for this

The echo handler

The X-Forwarded-For trust problem

/delay/:ms and why it's exactly one line

Testing without binding a socket

Graceful shutdown

Tradeoffs

Try it in 30 seconds Every team I've ever worked on eventually rebuilds httpbin, in some shape. Webhook sender says it sent the request, but did it? Ingress says it forwards X-Forwarded-For, but which values exactly? CORS preflight is failing and you're not sure what the browser is actually putting on the wire. I got tired of rebuilding the same toy service in Python every time and wrote one in Rust that fits in a 9.7 MB container. 📦 GitHub: https://github.com/sen-ltd/axum-echo httpbin.org is the reflex answer. Someone asks "where can I send a request to see what it looks like?" and someone else says "httpbin.org/anything". It works great when you're at your laptop with open internet. It stops working the moment you're debugging anything interesting: What you actually want is a tiny binary you can docker run -p 8000:8000 next to whatever you're debugging, that echoes back everything it received, with JSON formatting good enough to | jq through. webhook.site is slightly better — it gives you a unique URL and a web UI — but it's still hosted, and "web UI" isn't useful when you want to curl | jq .headers.authorization from a shell script. This is exactly the niche for axum. The binary I wrote is one file of routing, four handler modules, 35 tests, and a Dockerfile. The final container is 9.7 MB. It does every useful thing I've ever wanted from a debug server and nothing else. I've written this kind of tool in Python (flask, because it fits in 15 lines), in Node (express, because it fits in 12), and in Go (net/http, because it fits in 40 and the binary is small). Rust isn't the obvious choice — but once you've decided on Rust, axum is the obvious framework: The whole router in src/app.rs is 22 lines: Two things worth noticing here. First, build_app is a free function that returns a Router, not a method on some server struct. That matters because it makes the router directly testable — the integration tests call app::build_app().oneshot(request) without ever binding a TCP socket. Second, any(echo::echo) accepts any HTTP method, which is exactly what a debug echo endpoint needs — you want curl -X PURGE /echo to work when you're debugging whether your proxy handles non-standard methods. The /echo handler is where the interesting shape decisions live. I want the response to include: method, path, query, headers, body, client IP, and a timestamp. I want the body to be human-readable JSON when the request body was text, and base64 when it was binary. I want headers sorted so diffs are stable. req.into_parts() splits the incoming request into its metadata (parts) and its body. The body is a stream; axum::body::to_bytes collects it. The usize::MAX limit is intentional — a debug server that silently truncates bodies would lie to its user, and this is running on a single pod with a gigabyte of RAM, not serving the open internet. The classification into body_text vs body_base64 is a short helper: Note the all(is_printable) step: a body that parses as valid UTF-8 but contains \x00 or \x07 goes to base64, because the alternative is "stick non-printing bytes into your JSON string and hope your terminal can display them". Been burned by that one. The #[serde(skip_serializing_if = "Option::is_none")] attribute on the fields means an empty body produces {"body_bytes": 0} without littering "body_text": null and "body_base64": null in the response. Every tool in this space eventually runs into the same question: what is the client's IP address? The naive answer is "the peer address of the TCP socket". That's correct when the client is connecting directly. It's wrong the moment there's a reverse proxy (nginx, Traefik, Envoy, AWS ALB, GCP load balancer, Cloudflare, you name it), because then the peer address is the proxy's internal address, and the real client IP is in the X-Forwarded-For header. Here's the canonical extractor: The left-most entry in X-Forwarded-For is the original client, per the convention used by essentially every proxy. Subsequent entries are intermediate hops. X-Real-IP is a nginx-ism that some proxies set instead. Here's the part that feels unsafe if you haven't thought about it. Those headers are set by the client. Any HTTP client can stick X-Forwarded-For: 127.0.0.1 on a request and lie about where it came from. Web application firewalls that block internal IPs and then trust X-Forwarded-For have been a source of CVEs for 15 years now. The rule is: you can only trust these headers when a proxy you control (a) terminates the inbound connection and (b) strips any existing X-Forwarded-For value before adding its own. If either of those isn't true, the header is controlled by the client and cannot be trusted. axum-echo doesn't authenticate anyone. Its job is to tell you what it received, so it reports the header as-is and documents the problem. A debug service that silently ignored X-Forwarded-For because "it's not safe" would be useless for its actual purpose, which is to help you debug your proxy layer. The test coverage here matters: I wrote those first, because every time I've implemented this extractor from scratch I've gotten it subtly wrong — forgetting to trim whitespace, or returning the last entry instead of the first, or panicking on empty XFF values. The point of /delay/:ms is testing timeouts. You set your HTTP client's timeout to 500 ms, then call /delay/1000, and verify that the client aborts correctly. Simple, and exactly what tokio::time::sleep was built for: The cap at 30 seconds matters. Without it, a single call to /delay/999999999 would hold a tokio task hostage for 31 years. Tokio is perfectly happy to let you do that, because tokio is not going to second-guess your timing primitives. The cap is a product decision: this is a debug tool, nobody is legitimately waiting longer than 30 seconds, and the cap stops it from being accidentally weaponized. Because tokio's scheduler is cooperative, ten thousand simultaneous /delay/30000 calls are basically free — they park themselves on the timer wheel and use no CPU at all. That's a nice property you wouldn't get from naive thread-per-request models. The house style for testing HTTP servers in Rust is to pick between: I went with option 2. One fewer dev-dependency, no port allocation, no TLS setup. The tests just build the router and feed it pre-constructed Request values: oneshot takes the router (which is just a Service<Request>) and drives exactly one request through it. There's no async runtime overhead beyond the test's own #[tokio::test]. The whole integration suite — 19 tests — runs in 80 milliseconds. This required one structural concession: the crate has both a lib.rs and a main.rs. The library exposes pub mod app, and the binary is use axum_echo::app;. Without the library, integration tests (which can only see the public surface of a library, not binary internals) would have to duplicate the module tree with #[path = "../src/app.rs"] imports, which gets ugly fast once modules start importing each other. The main.rs binds a listener and calls axum::serve(...).with_graceful_shutdown(shutdown_signal()). The shutdown future waits for either Ctrl+C or, on Unix, SIGTERM: This matters in Kubernetes. When a pod is evicted, the kubelet sends SIGTERM and waits terminationGracePeriodSeconds before sending SIGKILL. Without with_graceful_shutdown, axum would keep accepting new connections until SIGKILL, and any in-flight requests would be cut off mid-response. With it, axum stops accepting new connections, finishes the in-flight ones, and exits cleanly. This is intentionally not a full-featured service. The things it deliberately doesn't do: None of those are oversights. They're all one-line additions that I deliberately left out because the point of the tool is "one thing well, in 9.7 MB". The image is 9.7 MB. The dependency graph at runtime is "the Linux kernel and musl libc". It pulls in eight crates at build time (axum, tokio, tower, serde, serde_json, uuid, rand, anyhow, base64). There is no config file, no environment variable anyone will forget to set, no database, no secret, no TLS. Next time you're debugging something at the HTTP layer, drop it into your compose file or your cluster and stop wondering what your proxy is actually forwarding. axum-echo is part of a 100-project portfolio I'm building at SEN LLC. Source on GitHub. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ use axum::{routing::{any, get}, Router}; use crate::logging; use crate::routes::{debug, echo, health}; pub fn build_app() -> Router { Router::new() .route("/", get(health::index)) .route("/health", get(health::health)) .route("/echo", any(echo::echo)) .route("/headers", get(debug::headers)) .route("/ip", get(debug::ip)) .route("/uuid", get(debug::uuid)) .route("/-weight: 500;">status/:code", get(debug::-weight: 500;">status)) .route("/delay/:ms", get(debug::delay)) .route("/bytes/:n", get(debug::bytes)) .layer(axum::middleware::from_fn(logging::log_requests)) } use axum::{routing::{any, get}, Router}; use crate::logging; use crate::routes::{debug, echo, health}; pub fn build_app() -> Router { Router::new() .route("/", get(health::index)) .route("/health", get(health::health)) .route("/echo", any(echo::echo)) .route("/headers", get(debug::headers)) .route("/ip", get(debug::ip)) .route("/uuid", get(debug::uuid)) .route("/-weight: 500;">status/:code", get(debug::-weight: 500;">status)) .route("/delay/:ms", get(debug::delay)) .route("/bytes/:n", get(debug::bytes)) .layer(axum::middleware::from_fn(logging::log_requests)) } use axum::{routing::{any, get}, Router}; use crate::logging; use crate::routes::{debug, echo, health}; pub fn build_app() -> Router { Router::new() .route("/", get(health::index)) .route("/health", get(health::health)) .route("/echo", any(echo::echo)) .route("/headers", get(debug::headers)) .route("/ip", get(debug::ip)) .route("/uuid", get(debug::uuid)) .route("/-weight: 500;">status/:code", get(debug::-weight: 500;">status)) .route("/delay/:ms", get(debug::delay)) .route("/bytes/:n", get(debug::bytes)) .layer(axum::middleware::from_fn(logging::log_requests)) } pub async fn echo(req: Request) -> impl IntoResponse { let (parts, body) = req.into_parts(); let method = parts.method.clone(); let uri = parts.uri.clone(); let headers = parts.headers.clone(); let connect_info: Option<ConnectInfo<SocketAddr>> = parts.extensions.get().cloned(); let peer: Option<SocketAddr> = connect_info.map(|ci| ci.0); let bytes = axum::body::to_bytes(body, usize::MAX) .await .unwrap_or_default(); Json(build_echo(&method, &uri, &headers, peer, &bytes)) } pub async fn echo(req: Request) -> impl IntoResponse { let (parts, body) = req.into_parts(); let method = parts.method.clone(); let uri = parts.uri.clone(); let headers = parts.headers.clone(); let connect_info: Option<ConnectInfo<SocketAddr>> = parts.extensions.get().cloned(); let peer: Option<SocketAddr> = connect_info.map(|ci| ci.0); let bytes = axum::body::to_bytes(body, usize::MAX) .await .unwrap_or_default(); Json(build_echo(&method, &uri, &headers, peer, &bytes)) } pub async fn echo(req: Request) -> impl IntoResponse { let (parts, body) = req.into_parts(); let method = parts.method.clone(); let uri = parts.uri.clone(); let headers = parts.headers.clone(); let connect_info: Option<ConnectInfo<SocketAddr>> = parts.extensions.get().cloned(); let peer: Option<SocketAddr> = connect_info.map(|ci| ci.0); let bytes = axum::body::to_bytes(body, usize::MAX) .await .unwrap_or_default(); Json(build_echo(&method, &uri, &headers, peer, &bytes)) } fn classify_body(body: &Bytes) -> (Option<String>, Option<String>) { if body.is_empty() { return (None, None); } match std::str::from_utf8(body) { Ok(s) if s.chars().all(is_printable) => (Some(s.to_string()), None), _ => (None, Some(B64.encode(body))), } } fn is_printable(c: char) -> bool { c == '\n' || c == '\r' || c == '\t' || !c.is_control() } fn classify_body(body: &Bytes) -> (Option<String>, Option<String>) { if body.is_empty() { return (None, None); } match std::str::from_utf8(body) { Ok(s) if s.chars().all(is_printable) => (Some(s.to_string()), None), _ => (None, Some(B64.encode(body))), } } fn is_printable(c: char) -> bool { c == '\n' || c == '\r' || c == '\t' || !c.is_control() } fn classify_body(body: &Bytes) -> (Option<String>, Option<String>) { if body.is_empty() { return (None, None); } match std::str::from_utf8(body) { Ok(s) if s.chars().all(is_printable) => (Some(s.to_string()), None), _ => (None, Some(B64.encode(body))), } } fn is_printable(c: char) -> bool { c == '\n' || c == '\r' || c == '\t' || !c.is_control() } pub fn extract_client_ip(headers: &HeaderMap, peer: Option<SocketAddr>) -> String { if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { if let Some(first) = xff.split(',').next() { let trimmed = first.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } } if let Some(real) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) { let trimmed = real.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } peer.map(|p| p.ip().to_string()) .unwrap_or_else(|| "unknown".to_string()) } pub fn extract_client_ip(headers: &HeaderMap, peer: Option<SocketAddr>) -> String { if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { if let Some(first) = xff.split(',').next() { let trimmed = first.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } } if let Some(real) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) { let trimmed = real.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } peer.map(|p| p.ip().to_string()) .unwrap_or_else(|| "unknown".to_string()) } pub fn extract_client_ip(headers: &HeaderMap, peer: Option<SocketAddr>) -> String { if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { if let Some(first) = xff.split(',').next() { let trimmed = first.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } } if let Some(real) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) { let trimmed = real.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } peer.map(|p| p.ip().to_string()) .unwrap_or_else(|| "unknown".to_string()) } #[test] fn prefers_xff_first_entry() { let peer: SocketAddr = "10.0.0.1:1234".parse().unwrap(); let h = hm(&[("x-forwarded-for", "203.0.113.9, 10.0.0.1")]); assert_eq!(extract_client_ip(&h, Some(peer)), "203.0.113.9"); } #[test] fn xff_trims_whitespace() { let h = hm(&[("x-forwarded-for", " 198.51.100.7 , 10.0.0.1")]); assert_eq!(extract_client_ip(&h, None), "198.51.100.7"); } #[test] fn prefers_xff_first_entry() { let peer: SocketAddr = "10.0.0.1:1234".parse().unwrap(); let h = hm(&[("x-forwarded-for", "203.0.113.9, 10.0.0.1")]); assert_eq!(extract_client_ip(&h, Some(peer)), "203.0.113.9"); } #[test] fn xff_trims_whitespace() { let h = hm(&[("x-forwarded-for", " 198.51.100.7 , 10.0.0.1")]); assert_eq!(extract_client_ip(&h, None), "198.51.100.7"); } #[test] fn prefers_xff_first_entry() { let peer: SocketAddr = "10.0.0.1:1234".parse().unwrap(); let h = hm(&[("x-forwarded-for", "203.0.113.9, 10.0.0.1")]); assert_eq!(extract_client_ip(&h, Some(peer)), "203.0.113.9"); } #[test] fn xff_trims_whitespace() { let h = hm(&[("x-forwarded-for", " 198.51.100.7 , 10.0.0.1")]); assert_eq!(extract_client_ip(&h, None), "198.51.100.7"); } pub async fn delay(Path(ms): Path<u64>) -> impl IntoResponse { let capped = ms.min(DELAY_CAP_MS); tokio::time::sleep(Duration::from_millis(capped)).await; Json(json!({ "delayed_ms": capped })) } pub async fn delay(Path(ms): Path<u64>) -> impl IntoResponse { let capped = ms.min(DELAY_CAP_MS); tokio::time::sleep(Duration::from_millis(capped)).await; Json(json!({ "delayed_ms": capped })) } pub async fn delay(Path(ms): Path<u64>) -> impl IntoResponse { let capped = ms.min(DELAY_CAP_MS); tokio::time::sleep(Duration::from_millis(capped)).await; Json(json!({ "delayed_ms": capped })) } #[tokio::test] async fn echo_post_text_body() { let app = app::build_app(); let res = app .oneshot( Request::builder() .method("POST") .uri("/echo") .header("content-type", "application/json") .body(Body::from(r#"{"hello":"world"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(res.-weight: 500;">status(), StatusCode::OK); let json = body_json(res.into_body()).await; assert_eq!(json["method"], "POST"); assert_eq!(json["body_text"], r#"{"hello":"world"}"#); assert!(json["body_base64"].is_null()); assert_eq!(json["body_bytes"], 17); } #[tokio::test] async fn echo_post_text_body() { let app = app::build_app(); let res = app .oneshot( Request::builder() .method("POST") .uri("/echo") .header("content-type", "application/json") .body(Body::from(r#"{"hello":"world"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(res.-weight: 500;">status(), StatusCode::OK); let json = body_json(res.into_body()).await; assert_eq!(json["method"], "POST"); assert_eq!(json["body_text"], r#"{"hello":"world"}"#); assert!(json["body_base64"].is_null()); assert_eq!(json["body_bytes"], 17); } #[tokio::test] async fn echo_post_text_body() { let app = app::build_app(); let res = app .oneshot( Request::builder() .method("POST") .uri("/echo") .header("content-type", "application/json") .body(Body::from(r#"{"hello":"world"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(res.-weight: 500;">status(), StatusCode::OK); let json = body_json(res.into_body()).await; assert_eq!(json["method"], "POST"); assert_eq!(json["body_text"], r#"{"hello":"world"}"#); assert!(json["body_base64"].is_null()); assert_eq!(json["body_bytes"], 17); } async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c().await.expect("failed to -weight: 500;">install Ctrl+C handler"); }; #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("failed to -weight: 500;">install SIGTERM handler") .recv() .await; }; tokio::select! { _ = ctrl_c => {}, _ = terminate => {}, } } async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c().await.expect("failed to -weight: 500;">install Ctrl+C handler"); }; #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("failed to -weight: 500;">install SIGTERM handler") .recv() .await; }; tokio::select! { _ = ctrl_c => {}, _ = terminate => {}, } } async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c().await.expect("failed to -weight: 500;">install Ctrl+C handler"); }; #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("failed to -weight: 500;">install SIGTERM handler") .recv() .await; }; tokio::select! { _ = ctrl_c => {}, _ = terminate => {}, } } -weight: 500;">git clone https://github.com/sen-ltd/axum-echo && cd axum-echo -weight: 500;">docker build -t axum-echo . -weight: 500;">docker run --rm -p 8000:8000 axum-echo -weight: 500;">git clone https://github.com/sen-ltd/axum-echo && cd axum-echo -weight: 500;">docker build -t axum-echo . -weight: 500;">docker run --rm -p 8000:8000 axum-echo -weight: 500;">git clone https://github.com/sen-ltd/axum-echo && cd axum-echo -weight: 500;">docker build -t axum-echo . -weight: 500;">docker run --rm -p 8000:8000 axum-echo -weight: 500;">curl -s localhost:8000/echo -d '{"hello":"world"}' -H 'content-type: application/json' | jq -weight: 500;">curl -s localhost:8000/headers -H 'authorization: Bearer test' | jq -weight: 500;">curl -s localhost:8000/ip -H 'x-forwarded-for: 203.0.113.9, 10.0.0.1' | jq -weight: 500;">curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/-weight: 500;">status/418 -weight: 500;">curl -s localhost:8000/delay/500 | jq -weight: 500;">curl -s localhost:8000/echo -d '{"hello":"world"}' -H 'content-type: application/json' | jq -weight: 500;">curl -s localhost:8000/headers -H 'authorization: Bearer test' | jq -weight: 500;">curl -s localhost:8000/ip -H 'x-forwarded-for: 203.0.113.9, 10.0.0.1' | jq -weight: 500;">curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/-weight: 500;">status/418 -weight: 500;">curl -s localhost:8000/delay/500 | jq -weight: 500;">curl -s localhost:8000/echo -d '{"hello":"world"}' -H 'content-type: application/json' | jq -weight: 500;">curl -s localhost:8000/headers -H 'authorization: Bearer test' | jq -weight: 500;">curl -s localhost:8000/ip -H 'x-forwarded-for: 203.0.113.9, 10.0.0.1' | jq -weight: 500;">curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/-weight: 500;">status/418 -weight: 500;">curl -s localhost:8000/delay/500 | jq - Behind a corporate proxy that MITMs TLS and strips headers. You can't tell what your ingress stripped from what the corporate proxy stripped. - Inside a Kubernetes cluster where the pods can't reach the outside internet, and you want to verify that your Service → Ingress → NetworkPolicy chain actually delivers packets. - In CI, where every external call costs flaky test time and sometimes gets rate-limited because 500 other people on your CI provider had the same idea. - Against a reverse proxy you're configuring, where you need to iterate on proxy_set_header directives and see the result in under a second. - For load testing, where hitting httpbin.org from 10 000 concurrent connections is an abuse pattern you'd rather not be the face of. - The router is a single Router struct with .route("/path", handler) calls. Nothing to discover. - Handlers are plain async functions. Axum uses Rust's type system to figure out what to extract from the request — Json<T> pulls out a JSON body, Path<T> pulls out URL segments, HeaderMap pulls out headers. No macros, no attribute soup. - tower middleware composes cleanly. The whole logging pipeline is one axum::middleware::from_fn call. - It sits on top of hyper and tokio, so you inherit all the performance work done there without seeing it. - Pull in axum-test or reqwest and spin up a real TcpListener for each test. - Call Router::oneshot(request) directly via tower::ServiceExt::oneshot. - No HTTPS. Terminate TLS at your ingress or sidecar. A debug -weight: 500;">service that ships its own TLS stack is a debug -weight: 500;">service that has to think about certificate rotation, and that's not the job. - No HTTP/2 or HTTP/3. axum::serve uses hyper's HTTP/1.1 server. Good enough for everything I've ever debugged with it. If you need to test HTTP/2 behavior specifically, use a different tool. - No WebSocket echo. axum supports it via axum::extract::ws, but it'd double the test surface and WebSocket debugging has different needs (you want wire-level tools like wscat, not JSON echoes). - No request body limit. /echo reads the whole body into memory with usize::MAX. If you POST a 10 GB file to /echo, you will OOM the pod. The corollary is /bytes/:n, which is capped at 10 MB because a 10 GB random response will OOM your client too. - No authentication. This is a debug -weight: 500;">service. Put it behind network policy. - X-Forwarded-For is trusted unconditionally. Covered above — this is the correct behavior for a reporting tool, but you must not repurpose the response for access control.