Skip to main content

nomoreide_core/
http_inspector.rs

1//! A reverse proxy that watches one service's HTTP traffic.
2//!
3//! Stood in front of a running service on an ephemeral port: every request is
4//! forwarded upstream unchanged and reported to a callback, so the dashboard
5//! can show what a browser is actually asking the service for without the
6//! service knowing anything about it.
7//!
8//! **Transparent or it is useless.** The status, the headers and the body all
9//! pass through as they came, and a WebSocket upgrade is handed off as a raw
10//! byte pipe rather than being parsed. The one header that changes is `Host`,
11//! which is rewritten to the upstream so a service that vhosts on it still
12//! answers.
13//!
14//! **An upstream that refuses is a 502 from here**, and still an event — a
15//! request that failed to connect is exactly the sort a developer opened this
16//! to see.
17
18use 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/// One request, as the timeline records it.
28#[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
41/// A running inspector, and the way to take it down.
42pub struct HttpInspectorHandle {
43    pub port: u16,
44    shutdown: Option<tokio::sync::oneshot::Sender<()>>,
45}
46
47impl HttpInspectorHandle {
48    /// Stop accepting, and drop the connections still open.
49    ///
50    /// Taking the sender rather than closing a socket is what makes this safe
51    /// to call twice: the second call has nothing to send and does nothing.
52    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
65/// Stand an inspector in front of `upstream_port`.
66///
67/// Binds loopback only. This forwards to a developer's own service with no
68/// authentication of its own, so it must not be a hole punched in the machine.
69pub 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
103/// One client connection, forwarded until either side goes quiet.
104///
105/// The request head is parsed only far enough to report it and rewrite `Host`;
106/// everything else — pipelining, chunked bodies, upgrades — is bytes, which is
107/// what keeps this transparent to protocols it has never heard of.
108async 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    // Read until the blank line that ends the request head, or until the client
119    // stops talking.
120    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    // The rest of the request body, forwarded and counted as it goes.
175    //
176    // Counted through a shared cell rather than returned, because this task
177    // cannot be waited on: a keep-alive client holds its socket open after the
178    // response, so joining here would park until the *client* went away and the
179    // event would never be reported.
180    // Seeded with whatever body arrived alongside the head: a small POST is one
181    // packet, so its whole body is already in hand and the pump below never
182    // sees it.
183    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    // The response. Every byte is forwarded, but only the *body* is counted —
200    // the reference counts what its HTTP parser hands it, which is the body
201    // alone, so counting the head here would inflate every row by the size of
202    // its headers.
203    //
204    // The stream ends when the upstream closes, which it does after one
205    // response because the head sent up asked it to — see `rewrite_head`.
206    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                // Whatever of the body came in the same read as the head.
225                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                // Not a response head this can make sense of; count the rest
230                // raw rather than buffering it forever.
231                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
256/// How many bytes of *body* a response carried.
257///
258/// Not how many bytes crossed the wire. A response with no content-length is
259/// chunked, and its wire form interleaves a hex length, the payload, and a
260/// terminator around every piece — eleven extra bytes on a single small
261/// response. The reference counts what its HTTP parser hands it, which is the
262/// payload alone, so the framing is parsed out here rather than counted.
263///
264/// The bytes are still *forwarded* untouched; only the tally sees the
265/// difference.
266struct BodyCounter {
267    total: usize,
268    chunked: bool,
269    state: ChunkState,
270    /// Partial size line, held across reads — a chunk header can be split.
271    pending: Vec<u8>,
272}
273
274enum ChunkState {
275    Size,
276    Data(usize),
277    /// The CRLF after a chunk's data, and how much of it is left.
278    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                    // A chunk header may carry extensions after a semicolon;
309                    // only the size in front of it is the size.
310                    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
346/// Whether a response head declares chunked transfer encoding.
347fn 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
357/// Rounded to a tenth of a millisecond, the way the reference rounds it.
358fn elapsed(started: Instant) -> f64 {
359    (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
360}
361
362/// Where the request head ends, counting the blank line.
363fn 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
370/// The method and target from the request line.
371fn 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
378/// The status code from a response head.
379fn 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
388/// Point `Host` at the upstream and ask it not to keep the connection alive.
389///
390/// Two rewrites, and only two. `Host` so a service that vhosts on it still
391/// answers.
392///
393/// `Connection: close` is the load-bearing one: without it a keep-alive
394/// upstream never closes, and since this proxy forwards bytes rather than
395/// parsing HTTP it has no other way to know where one response ended — so the
396/// request would be forwarded correctly and then never *reported*, which is the
397/// entire point of the inspector. The cost is one connection per request while
398/// the inspector is on, which is a throughput property of a debugging tool that
399/// is off by default.
400fn 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        // Dropped here and re-added at the blank line, so exactly one of each
422        // goes up whatever the client sent.
423        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    /// A chunk header split across two reads is still one header.
457    #[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    /// The host changes whatever case it arrived in, and nothing else does.
494    #[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    /// A request that arrived without one still gets one, because the upstream
504    /// is entitled to a `Host` on HTTP/1.1.
505    #[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    /// Exactly one `connection` header goes up, and it says close — whatever
513    /// the client asked for. Without this the byte pipe cannot see where a
514    /// response ended and no request is ever reported.
515    #[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}