1use std::sync::atomic::{AtomicUsize, Ordering};
19use std::sync::Arc;
20use std::time::Instant;
21
22use chrono::{DateTime, Utc};
23use serde::Serialize;
24use tokio::io::{AsyncReadExt, AsyncWriteExt};
25use tokio::net::{TcpListener, TcpStream};
26
27#[derive(Debug, Clone, Serialize)]
29#[serde(rename_all = "camelCase")]
30pub struct HttpInspectorEvent {
31 pub id: String,
32 pub started_at: DateTime<Utc>,
33 pub method: String,
34 pub path: String,
35 pub status: u16,
36 pub duration_ms: f64,
37 pub req_bytes: usize,
38 pub res_bytes: usize,
39}
40
41pub struct HttpInspectorHandle {
43 pub port: u16,
44 shutdown: Option<tokio::sync::oneshot::Sender<()>>,
45}
46
47impl HttpInspectorHandle {
48 pub fn stop(&mut self) {
53 if let Some(shutdown) = self.shutdown.take() {
54 let _ = shutdown.send(());
55 }
56 }
57}
58
59impl Drop for HttpInspectorHandle {
60 fn drop(&mut self) {
61 self.stop();
62 }
63}
64
65pub async fn start(
70 upstream_port: u16,
71 on_event: impl Fn(HttpInspectorEvent) + Send + Sync + 'static,
72) -> Result<HttpInspectorHandle, String> {
73 let listener = TcpListener::bind(("127.0.0.1", 0))
74 .await
75 .map_err(|error| format!("Could not start the inspector: {error}"))?;
76 let port = listener
77 .local_addr()
78 .map_err(|error| error.to_string())?
79 .port();
80 let (shutdown, mut stopped) = tokio::sync::oneshot::channel();
81 let on_event = Arc::new(on_event);
82
83 tokio::spawn(async move {
84 loop {
85 let accepted = tokio::select! {
86 _ = &mut stopped => return,
87 accepted = listener.accept() => accepted,
88 };
89 let Ok((client, _)) = accepted else { continue };
90 let on_event = on_event.clone();
91 tokio::spawn(async move {
92 let _ = proxy(client, upstream_port, on_event).await;
93 });
94 }
95 });
96
97 Ok(HttpInspectorHandle {
98 port,
99 shutdown: Some(shutdown),
100 })
101}
102
103async fn proxy(
109 mut client: TcpStream,
110 upstream_port: u16,
111 on_event: Arc<impl Fn(HttpInspectorEvent) + Send + Sync + 'static>,
112) -> std::io::Result<()> {
113 let started_at = Utc::now();
114 let started = Instant::now();
115
116 let mut head = Vec::new();
117 let mut buffer = [0u8; 8192];
118 let head_end = loop {
121 let read = client.read(&mut buffer).await?;
122 if read == 0 {
123 return Ok(());
124 }
125 head.extend_from_slice(&buffer[..read]);
126 if let Some(end) = find_head_end(&head) {
127 break end;
128 }
129 if head.len() > 64 * 1024 {
130 return Ok(());
131 }
132 };
133
134 let (method, path) = request_line(&head).unwrap_or_else(|| ("GET".into(), "/".into()));
135 let upstream_host = format!("127.0.0.1:{upstream_port}");
136 let rewritten = rewrite_head(&head[..head_end], &upstream_host);
137 let leftover = head[head_end..].to_vec();
138
139 let mut upstream = match TcpStream::connect(("127.0.0.1", upstream_port)).await {
140 Ok(upstream) => upstream,
141 Err(error) => {
142 let body = format!("Inspector upstream error: {error}");
143 let _ = client
144 .write_all(
145 format!(
146 "HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
147 body.len()
148 )
149 .as_bytes(),
150 )
151 .await;
152 on_event(HttpInspectorEvent {
153 id: uuid::Uuid::new_v4().to_string(),
154 started_at,
155 method,
156 path,
157 status: 502,
158 duration_ms: elapsed(started),
159 req_bytes: 0,
160 res_bytes: 0,
161 });
162 return Ok(());
163 }
164 };
165
166 upstream.write_all(&rewritten).await?;
167 if !leftover.is_empty() {
168 upstream.write_all(&leftover).await?;
169 }
170
171 let (mut client_read, mut client_write) = client.into_split();
172 let (mut upstream_read, mut upstream_write) = upstream.into_split();
173
174 let request_bytes = Arc::new(AtomicUsize::new(leftover.len()));
184 let counted = request_bytes.clone();
185 tokio::spawn(async move {
186 let mut buffer = [0u8; 8192];
187 while let Ok(read) = client_read.read(&mut buffer).await {
188 if read == 0 {
189 break;
190 }
191 counted.fetch_add(read, Ordering::Relaxed);
192 if upstream_write.write_all(&buffer[..read]).await.is_err() {
193 break;
194 }
195 }
196 let _ = upstream_write.shutdown().await;
197 });
198
199 let mut body = BodyCounter::new(false);
207 let mut status = 0u16;
208 let mut head_seen = false;
209 let mut response_head: Vec<u8> = Vec::new();
210 let mut buffer = [0u8; 8192];
211 loop {
212 let read = match upstream_read.read(&mut buffer).await {
213 Ok(0) | Err(_) => break,
214 Ok(read) => read,
215 };
216 if head_seen {
217 body.feed(&buffer[..read]);
218 } else {
219 response_head.extend_from_slice(&buffer[..read]);
220 if let Some(end) = find_head_end(&response_head) {
221 head_seen = true;
222 status = status_of(&response_head).unwrap_or(0);
223 body = BodyCounter::new(is_chunked(&response_head));
224 let carried = response_head[end..].to_vec();
226 body.feed(&carried);
227 response_head = Vec::new();
228 } else if response_head.len() > 64 * 1024 {
229 head_seen = true;
232 response_head = Vec::new();
233 }
234 }
235 if client_write.write_all(&buffer[..read]).await.is_err() {
236 break;
237 }
238 }
239 let _ = client_write.shutdown().await;
240
241 let response_bytes = body.total;
242 let req_bytes = request_bytes.load(Ordering::Relaxed);
243 on_event(HttpInspectorEvent {
244 id: uuid::Uuid::new_v4().to_string(),
245 started_at,
246 method,
247 path,
248 status,
249 duration_ms: elapsed(started),
250 req_bytes,
251 res_bytes: response_bytes,
252 });
253 Ok(())
254}
255
256struct BodyCounter {
267 total: usize,
268 chunked: bool,
269 state: ChunkState,
270 pending: Vec<u8>,
272}
273
274enum ChunkState {
275 Size,
276 Data(usize),
277 Crlf(usize),
279 Done,
280}
281
282impl BodyCounter {
283 fn new(chunked: bool) -> Self {
284 Self {
285 total: 0,
286 chunked,
287 state: ChunkState::Size,
288 pending: Vec::new(),
289 }
290 }
291
292 fn feed(&mut self, mut bytes: &[u8]) {
293 if !self.chunked {
294 self.total += bytes.len();
295 return;
296 }
297 while !bytes.is_empty() {
298 match self.state {
299 ChunkState::Done => return,
300 ChunkState::Size => {
301 let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') else {
302 self.pending.extend_from_slice(bytes);
303 return;
304 };
305 self.pending.extend_from_slice(&bytes[..newline]);
306 bytes = &bytes[newline + 1..];
307 let line = String::from_utf8_lossy(&self.pending);
308 let size = usize::from_str_radix(
311 line.trim().split(';').next().unwrap_or_default().trim(),
312 16,
313 )
314 .unwrap_or(0);
315 self.pending.clear();
316 self.state = if size == 0 {
317 ChunkState::Done
318 } else {
319 ChunkState::Data(size)
320 };
321 }
322 ChunkState::Data(remaining) => {
323 let taken = remaining.min(bytes.len());
324 self.total += taken;
325 bytes = &bytes[taken..];
326 self.state = if taken == remaining {
327 ChunkState::Crlf(2)
328 } else {
329 ChunkState::Data(remaining - taken)
330 };
331 }
332 ChunkState::Crlf(remaining) => {
333 let taken = remaining.min(bytes.len());
334 bytes = &bytes[taken..];
335 self.state = if taken == remaining {
336 ChunkState::Size
337 } else {
338 ChunkState::Crlf(remaining - taken)
339 };
340 }
341 }
342 }
343 }
344}
345
346fn is_chunked(head: &[u8]) -> bool {
348 String::from_utf8_lossy(head).lines().any(|line| {
349 let Some((name, value)) = line.split_once(':') else {
350 return false;
351 };
352 name.trim().eq_ignore_ascii_case("transfer-encoding")
353 && value.to_ascii_lowercase().contains("chunked")
354 })
355}
356
357fn elapsed(started: Instant) -> f64 {
359 (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
360}
361
362fn find_head_end(bytes: &[u8]) -> Option<usize> {
364 bytes
365 .windows(4)
366 .position(|window| window == b"\r\n\r\n")
367 .map(|at| at + 4)
368}
369
370fn request_line(head: &[u8]) -> Option<(String, String)> {
372 let line = head.split(|byte| *byte == b'\n').next()?;
373 let text = String::from_utf8_lossy(line);
374 let mut parts = text.trim_end().split(' ');
375 Some((parts.next()?.to_string(), parts.next()?.to_string()))
376}
377
378fn status_of(head: &[u8]) -> Option<u16> {
380 let line = head.split(|byte| *byte == b'\n').next()?;
381 String::from_utf8_lossy(line)
382 .split(' ')
383 .nth(1)?
384 .parse()
385 .ok()
386}
387
388fn rewrite_head(head: &[u8], upstream_host: &str) -> Vec<u8> {
401 let text = String::from_utf8_lossy(head);
402 let mut out = String::with_capacity(text.len());
403 let mut wrote_host = false;
404 for line in text.split_inclusive("\r\n") {
405 let trimmed = line.trim_end_matches("\r\n");
406 if trimmed.is_empty() {
407 if !wrote_host {
408 out.push_str(&format!("host: {upstream_host}\r\n"));
409 wrote_host = true;
410 }
411 out.push_str("connection: close\r\n");
412 out.push_str(line);
413 continue;
414 }
415 let name = trimmed.split(':').next().unwrap_or_default();
416 if name.eq_ignore_ascii_case("host") {
417 out.push_str(&format!("host: {upstream_host}\r\n"));
418 wrote_host = true;
419 continue;
420 }
421 if name.eq_ignore_ascii_case("connection") || name.eq_ignore_ascii_case("keep-alive") {
424 continue;
425 }
426 out.push_str(line);
427 }
428 out.into_bytes()
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
436 fn a_head_ends_at_the_blank_line() {
437 assert_eq!(find_head_end(b"GET / HTTP/1.1\r\n\r\nbody"), Some(18));
438 assert_eq!(find_head_end(b"GET / HTTP/1.1\r\nhost: x\r\n"), None);
439 }
440
441 #[test]
442 fn the_request_line_gives_the_method_and_target() {
443 assert_eq!(
444 request_line(b"POST /a?b=1 HTTP/1.1\r\n"),
445 Some(("POST".to_string(), "/a?b=1".to_string()))
446 );
447 }
448
449 #[test]
450 fn a_chunked_body_counts_only_its_payload() {
451 let mut counter = BodyCounter::new(true);
452 counter.feed(b"5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n");
453 assert_eq!(counter.total, 11);
454 }
455
456 #[test]
458 fn a_chunk_split_across_reads_is_counted_once() {
459 let mut counter = BodyCounter::new(true);
460 counter.feed(b"5\r\nhel");
461 counter.feed(b"lo\r\n0\r\n\r\n");
462 assert_eq!(counter.total, 5);
463 }
464
465 #[test]
466 fn an_unchunked_body_is_counted_as_it_comes() {
467 let mut counter = BodyCounter::new(false);
468 counter.feed(b"hello");
469 counter.feed(b" world");
470 assert_eq!(counter.total, 11);
471 }
472
473 #[test]
474 fn chunked_is_read_off_the_head_whatever_its_case() {
475 assert!(is_chunked(
476 b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
477 ));
478 assert!(is_chunked(
479 b"HTTP/1.1 200 OK\r\ntransfer-encoding: Chunked\r\n\r\n"
480 ));
481 assert!(!is_chunked(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n"));
482 }
483
484 #[test]
485 fn the_status_comes_off_the_response_line() {
486 assert_eq!(
487 status_of(b"HTTP/1.1 503 Service Unavailable\r\n"),
488 Some(503)
489 );
490 assert_eq!(status_of(b"garbage\r\n"), None);
491 }
492
493 #[test]
495 fn the_host_is_pointed_upstream() {
496 let head = b"GET / HTTP/1.1\r\nHost: example.test\r\nAccept: */*\r\n\r\n";
497 let out = String::from_utf8(rewrite_head(head, "127.0.0.1:9000")).unwrap();
498 assert!(out.contains("host: 127.0.0.1:9000\r\n"));
499 assert!(!out.contains("example.test"));
500 assert!(out.contains("Accept: */*\r\n"));
501 }
502
503 #[test]
506 fn a_missing_host_is_added() {
507 let head = b"GET / HTTP/1.1\r\nAccept: */*\r\n\r\n";
508 let out = String::from_utf8(rewrite_head(head, "127.0.0.1:9000")).unwrap();
509 assert!(out.contains("host: 127.0.0.1:9000\r\n"));
510 }
511
512 #[test]
516 fn the_upstream_is_asked_to_close() {
517 let head =
518 b"GET / HTTP/1.1\r\nHost: x\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\n\r\n";
519 let out = String::from_utf8(rewrite_head(head, "127.0.0.1:9000")).unwrap();
520 assert_eq!(out.matches("connection: close").count(), 1);
521 assert!(!out.to_lowercase().contains("keep-alive"));
522 assert!(out.ends_with("connection: close\r\n\r\n"));
523 }
524}