Skip to main content

lobe_core/engine/
capture.rs

1use std::net::SocketAddr;
2use std::sync::mpsc::Sender;
3use std::sync::Arc;
4use std::time::{Instant, SystemTime, UNIX_EPOCH};
5
6use serde::{Deserialize, Serialize};
7use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
8use tokio::net::{lookup_host, TcpListener, TcpStream};
9use tokio_rustls::rustls::pki_types::ServerName;
10use tokio_rustls::rustls::{ClientConfig, RootCertStore};
11use tokio_rustls::TlsConnector;
12use url::Url;
13use webpki_roots::TLS_SERVER_ROOTS;
14
15use crate::engine::pool::ConnectionPool;
16use crate::engine::proxy::ProxyMeasurement;
17use crate::engine::tls::{report_for_http, report_for_https_failure, report_for_https_success};
18use crate::error::{Result, TloxError};
19use crate::models::{TimingReport, TlsReport};
20
21/// On-disk format for `lobe capture` session exports (also read by `lobe explain`).
22/// Bumping `version` is a breaking change to the file format.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct CaptureSessionExport {
25    pub version: u8,
26    pub listen_addr: String,
27    pub upstream: String,
28    pub exported_at_ms: i64,
29    pub event_count: usize,
30    pub events: Vec<CapturedExchange>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct CapturedExchange {
35    pub target: String,
36    pub request_host: String,
37    pub request_method: String,
38    pub request_path: String,
39    pub request_headers: Vec<(String, String)>,
40    pub response_headers: Vec<(String, String)>,
41    pub protocol_version: Option<String>,
42    pub redirect_location: Option<String>,
43    pub response_status_code: Option<u16>,
44    pub response_status_text: Option<String>,
45    pub response_bytes: u64,
46    pub report: TimingReport,
47    pub tls: Option<TlsReport>,
48    pub error_message: Option<String>,
49    pub created_at_ms: i64,
50    /// Whether the underlying TCP/TLS/HTTP-2 connection was newly opened for
51    /// this request or reused from a previous one. Downstream anomaly
52    /// detection uses this to skip DNS/TCP/TLS baseline checks on warm
53    /// requests (those phases should be ~0 on reuse). Currently always
54    /// `"new"` because Lobe opens a fresh connection per request — the real
55    /// pool lands as a follow-up "Production Ready" milestone.
56    #[serde(default = "default_connection_reuse")]
57    pub connection_reuse: String,
58}
59
60fn default_connection_reuse() -> String {
61    "new".to_string()
62}
63
64#[derive(Debug)]
65pub struct CaptureProxy {
66    listen_addr: String,
67    upstream: Url,
68    sender: Sender<CapturedExchange>,
69    /// Shared across all incoming connections so requests to the same
70    /// upstream benefit from connection reuse — HTTP/2 stream multiplexing
71    /// on top of a single TCP+TLS session.
72    pool: ConnectionPool,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76struct IncomingRequest {
77    method: String,
78    path: String,
79    version: String,
80    headers: Vec<(String, String)>,
81    body: Vec<u8>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85struct ForwardResponse {
86    raw_response: Vec<u8>,
87    metadata: ResponseMetadata,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91struct ResponseMetadata {
92    total_ms: u64,
93    status_code: Option<u16>,
94    status_text: Option<String>,
95    bytes: u64,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99struct UpstreamResponse {
100    status_code: u16,
101    status_text: String,
102    protocol_version: String,
103    response_headers: Vec<(String, String)>,
104    redirect_location: Option<String>,
105    body: Vec<u8>,
106}
107
108impl CaptureProxy {
109    pub fn new(listen_addr: String, upstream: &str, sender: Sender<CapturedExchange>) -> Result<Self> {
110        let upstream = Url::parse(upstream)
111            .map_err(|_| TloxError::InvalidTarget(upstream.to_string()))?;
112
113        Ok(Self {
114            listen_addr,
115            upstream,
116            sender,
117            pool: ConnectionPool::new(),
118        })
119    }
120
121    pub fn listen_addr(&self) -> &str {
122        &self.listen_addr
123    }
124
125    pub fn upstream(&self) -> &Url {
126        &self.upstream
127    }
128
129    pub async fn run(self) -> Result<()> {
130        let listener = TcpListener::bind(&self.listen_addr).await?;
131        let upstream = self.upstream.clone();
132        let sender = self.sender.clone();
133        let pool = self.pool.clone();
134
135        loop {
136            let (stream, _) = listener.accept().await?;
137            let upstream = upstream.clone();
138            let sender = sender.clone();
139            let pool = pool.clone();
140
141            tokio::spawn(async move {
142                let error_target = upstream.to_string();
143                if let Err(error) =
144                    handle_connection(stream, upstream, sender.clone(), pool).await
145                {
146                    let exchange = CapturedExchange {
147                        target: error_target,
148                        request_host: "n/a".to_string(),
149                        request_method: "UNKNOWN".to_string(),
150                        request_path: "/".to_string(),
151                        request_headers: Vec::new(),
152                        response_headers: Vec::new(),
153                        protocol_version: None,
154                        redirect_location: None,
155                        response_status_code: Some(502),
156                        response_status_text: Some("Bad Gateway".to_string()),
157                        response_bytes: 0,
158                        report: TimingReport::default(),
159                        tls: None,
160                        error_message: Some(error.to_string()),
161                        created_at_ms: current_time_ms().unwrap_or_default(),
162                        connection_reuse: "new".to_string(),
163                    };
164                    let _ = sender.send(exchange);
165                }
166            });
167        }
168    }
169}
170
171async fn handle_connection(
172    mut inbound: TcpStream,
173    upstream: Url,
174    sender: Sender<CapturedExchange>,
175    pool: ConnectionPool,
176) -> Result<()> {
177    let Some(request) = read_http_request(&mut inbound).await? else {
178        return Ok(());
179    };
180
181    let result = forward_request(&upstream, &request, &pool).await;
182
183    match result {
184        Ok((response, exchange)) => {
185            inbound.write_all(&response.raw_response).await?;
186            inbound.flush().await?;
187            let _ = sender.send(exchange);
188        }
189        Err((exchange, response_bytes)) => {
190            inbound.write_all(&response_bytes).await?;
191            inbound.flush().await?;
192            let _ = sender.send(exchange);
193        }
194    }
195
196    Ok(())
197}
198
199async fn forward_request(
200    upstream: &Url,
201    request: &IncomingRequest,
202    pool: &ConnectionPool,
203) -> std::result::Result<(ForwardResponse, CapturedExchange), (CapturedExchange, Vec<u8>)> {
204    let is_https = upstream.scheme() == "https";
205    let target_url = match build_upstream_url(upstream, &request.path) {
206        Ok(url) => url,
207        Err(error) => {
208            let exchange = error_exchange(
209                upstream,
210                request,
211                TimingReport::default(),
212                tls_report_for_failure(is_https, error.to_string()),
213                error.to_string(),
214            );
215            return Err((exchange, bad_gateway_response("invalid upstream url")));
216        }
217    };
218
219    let host = match target_url.host_str() {
220        Some(host) => host.to_string(),
221        None => {
222            let message = "upstream is missing a host".to_string();
223            let exchange = error_exchange(
224                upstream,
225                request,
226                TimingReport::default(),
227                tls_report_for_failure(is_https, message.clone()),
228                message,
229            );
230            return Err((exchange, bad_gateway_response("upstream missing host")));
231        }
232    };
233
234    let port = match target_url.port_or_known_default() {
235        Some(port) => port,
236        None => {
237            let message = "upstream is missing a port".to_string();
238            let exchange = error_exchange(
239                upstream,
240                request,
241                TimingReport::default(),
242                tls_report_for_failure(is_https, message.clone()),
243                message,
244            );
245            return Err((exchange, bad_gateway_response("upstream missing port")));
246        }
247    };
248
249    let mut measurement = ProxyMeasurement::new();
250    let total_start = Instant::now();
251
252    // Warm path — if we already have an HTTP/2 sender cached for this host,
253    // skip DNS+TCP+TLS+h2-handshake entirely and multiplex a new stream over
254    // the existing connection. All connection-setup phases stay at 0ms
255    // because the underlying transport is already established.
256    if is_https {
257        if let Some(cached_sender) = pool.get_h2(host.as_str(), port).await {
258            let request_target = build_request_target(&target_url);
259            let warm_outcome = send_over_h2(
260                cached_sender,
261                &host,
262                &request_target,
263                request,
264                &mut measurement,
265            )
266            .await;
267
268            match warm_outcome {
269                Ok(upstream_response) => {
270                    let total_ms = total_start.elapsed().as_millis() as u64;
271                    let mut report = measurement.finish_report();
272                    report.total_ms = total_ms;
273
274                    let raw_response = build_raw_response(
275                        upstream_response.status_code,
276                        &upstream_response.status_text,
277                        &upstream_response.response_headers,
278                        &upstream_response.body,
279                    );
280                    let metadata = ResponseMetadata {
281                        total_ms,
282                        status_code: Some(upstream_response.status_code),
283                        status_text: Some(upstream_response.status_text.clone()),
284                        bytes: upstream_response.body.len() as u64,
285                    };
286                    // Reused connection has no TLS handshake data to report
287                    // — the TLS metadata from the original handshake is
288                    // still valid but we don't re-record it per request.
289                    let tls_report = report_for_https_success(None, None, None);
290                    let mut exchange = success_exchange(
291                        &target_url,
292                        request,
293                        report,
294                        request.headers.clone(),
295                        upstream_response.response_headers.clone(),
296                        Some(upstream_response.protocol_version.clone()),
297                        upstream_response.redirect_location.clone(),
298                        Some(tls_report),
299                        metadata.clone(),
300                    );
301                    exchange.connection_reuse = "reused".to_string();
302
303                    return Ok((ForwardResponse { raw_response, metadata }, exchange));
304                }
305                Err(_error) => {
306                    // Cached sender is dead — evict and fall through to a
307                    // fresh handshake below. Don't fail the request just
308                    // because the pool went stale.
309                    pool.invalidate_h2(host.as_str(), port).await;
310                }
311            }
312        }
313    }
314
315    let dns_start = Instant::now();
316    let addr: SocketAddr = match lookup_host((host.as_str(), port)).await {
317        Ok(mut addresses) => match addresses.next() {
318            Some(addr) => addr,
319            None => {
320                let message = "no addresses resolved".to_string();
321                let report = finish_report(&measurement, total_start);
322                let tls = tls_report_for_failure(is_https, message.clone());
323                let exchange = error_exchange(upstream, request, report, tls, message);
324                return Err((exchange, bad_gateway_response("upstream unresolved")));
325            }
326        },
327        Err(error) => {
328            let report = finish_report(&measurement, total_start);
329            let tls = tls_report_for_failure(is_https, error.to_string());
330            let exchange = error_exchange(upstream, request, report, tls, error.to_string());
331            return Err((exchange, bad_gateway_response("dns resolution failed")));
332        }
333    };
334    measurement.record_dns_ms(dns_start.elapsed().as_millis() as u64);
335
336    let tcp_start = Instant::now();
337    let stream = match TcpStream::connect(addr).await {
338        Ok(stream) => stream,
339        Err(error) => {
340            let report = finish_report(&measurement, total_start);
341            let tls = tls_report_for_failure(is_https, error.to_string());
342            let exchange = error_exchange(upstream, request, report, tls, error.to_string());
343            return Err((exchange, bad_gateway_response("tcp connect failed")));
344        }
345    };
346    measurement.record_tcp_ms(tcp_start.elapsed().as_millis() as u64);
347
348    let request_target = build_request_target(&target_url);
349
350    let outcome = if is_https {
351        exchange_https(
352            stream,
353            &host,
354            port,
355            &request_target,
356            request,
357            &mut measurement,
358            pool,
359        )
360        .await
361    } else {
362        exchange_http(stream, &host, &request_target, request, &mut measurement).await
363    };
364
365    match outcome {
366        Ok((upstream_response, tls_report)) => {
367            let total_ms = total_start.elapsed().as_millis() as u64;
368            let mut report = measurement.finish_report();
369            report.total_ms = total_ms;
370
371            let raw_response = build_raw_response(
372                upstream_response.status_code,
373                &upstream_response.status_text,
374                &upstream_response.response_headers,
375                &upstream_response.body,
376            );
377
378            let metadata = ResponseMetadata {
379                total_ms,
380                status_code: Some(upstream_response.status_code),
381                status_text: Some(upstream_response.status_text.clone()),
382                bytes: upstream_response.body.len() as u64,
383            };
384
385            let exchange = success_exchange(
386                &target_url,
387                request,
388                report,
389                request.headers.clone(),
390                upstream_response.response_headers.clone(),
391                Some(upstream_response.protocol_version.clone()),
392                upstream_response.redirect_location.clone(),
393                Some(tls_report),
394                metadata.clone(),
395            );
396
397            Ok((
398                ForwardResponse {
399                    raw_response,
400                    metadata,
401                },
402                exchange,
403            ))
404        }
405        Err(error) => {
406            let report = finish_report(&measurement, total_start);
407            let tls = tls_report_for_failure(is_https, error.to_string());
408            let exchange = error_exchange(upstream, request, report, tls, error.to_string());
409            Err((exchange, bad_gateway_response("upstream exchange failed")))
410        }
411    }
412}
413
414async fn exchange_http(
415    mut stream: TcpStream,
416    host: &str,
417    request_target: &str,
418    request: &IncomingRequest,
419    measurement: &mut ProxyMeasurement,
420) -> std::io::Result<(UpstreamResponse, TlsReport)> {
421    let response = exchange_over_stream(&mut stream, host, request_target, request, measurement).await?;
422    Ok((response, report_for_http()))
423}
424
425async fn exchange_https(
426    stream: TcpStream,
427    host: &str,
428    port: u16,
429    request_target: &str,
430    request: &IncomingRequest,
431    measurement: &mut ProxyMeasurement,
432    pool: &ConnectionPool,
433) -> std::io::Result<(UpstreamResponse, TlsReport)> {
434    let tls_start = Instant::now();
435    let connector = build_tls_connector();
436    let server_name = ServerName::try_from(host.to_string())
437        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error.to_string()))?;
438
439    let mut tls_stream = connector
440        .connect(server_name, stream)
441        .await
442        .map_err(|error| std::io::Error::other(error))?;
443    measurement.record_tls_ms(tls_start.elapsed().as_millis() as u64);
444
445    // ALPN-branch: honor whichever protocol the server negotiated. HTTP/1.1
446    // stays on the proven raw-tokio path; HTTP/2 goes through the hyper-based
447    // h2 helper. The h1 path below is byte-for-byte unchanged from the
448    // pre-h2 shipping code.
449    let negotiated_h2 = {
450        let (_, connection) = tls_stream.get_ref();
451        connection.alpn_protocol() == Some(b"h2".as_slice())
452    };
453
454    // Capture TLS metadata BEFORE handing the stream off — after h2 handshake
455    // the stream is consumed by hyper and we can't inspect it anymore.
456    let tls_metadata = {
457        let (_, connection) = tls_stream.get_ref();
458        (
459            connection.protocol_version().map(|value| format!("{value:?}")),
460            connection
461                .negotiated_cipher_suite()
462                .map(|value| format!("{:?}", value.suite())),
463        )
464    };
465
466    let response = if negotiated_h2 {
467        exchange_over_h2(
468            tls_stream,
469            host,
470            port,
471            request_target,
472            request,
473            measurement,
474            pool,
475        )
476        .await?
477    } else {
478        exchange_over_stream(&mut tls_stream, host, request_target, request, measurement).await?
479    };
480
481    let (tls_version, cipher_suite) = tls_metadata;
482    let tls_report = report_for_https_success(tls_version, cipher_suite, None);
483
484    Ok((response, tls_report))
485}
486
487async fn exchange_over_stream<S>(
488    stream: &mut S,
489    host: &str,
490    request_target: &str,
491    request: &IncomingRequest,
492    measurement: &mut ProxyMeasurement,
493) -> std::io::Result<UpstreamResponse>
494where
495    S: AsyncRead + AsyncWrite + Unpin,
496{
497    let request_bytes = serialize_request(&request.method, request_target, host, &request.headers, &request.body);
498
499    let ttfb_start = Instant::now();
500    stream.write_all(&request_bytes).await?;
501    stream.flush().await?;
502
503    let mut buffer = Vec::new();
504    let mut chunk = [0_u8; 4096];
505    let mut download_start: Option<Instant> = None;
506
507    loop {
508        let bytes_read = stream.read(&mut chunk).await?;
509        if bytes_read == 0 {
510            break;
511        }
512
513        if download_start.is_none() {
514            measurement.record_ttfb_ms(ttfb_start.elapsed().as_millis() as u64);
515            download_start = Some(Instant::now());
516        }
517
518        buffer.extend_from_slice(&chunk[..bytes_read]);
519    }
520
521    if let Some(start) = download_start {
522        measurement.record_download_ms(start.elapsed().as_millis() as u64);
523    }
524
525    parse_upstream_response(&buffer)
526}
527
528/// HTTP/2 exchange over an already-TLS-handshaken stream. Called when ALPN
529/// negotiated `h2`. Uses `hyper` for the HTTP/2 protocol so we get proper stream
530/// multiplexing, HPACK, and flow control for free.
531///
532/// This path is entirely separate from the HTTP/1.1 raw-tokio implementation
533/// (`exchange_over_stream`). If h1 is negotiated, this function is never called
534/// and the h1 path is byte-for-byte unchanged.
535async fn exchange_over_h2<S>(
536    stream: S,
537    host: &str,
538    port: u16,
539    request_target: &str,
540    request: &IncomingRequest,
541    measurement: &mut ProxyMeasurement,
542    pool: &ConnectionPool,
543) -> std::io::Result<UpstreamResponse>
544where
545    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static,
546{
547    use http_body_util::Full;
548    use hyper::body::Bytes;
549    use hyper::client::conn::http2;
550    use hyper_util::rt::{TokioExecutor, TokioIo};
551
552    let io = TokioIo::new(stream);
553    let (sender, connection) = http2::handshake::<_, _, Full<Bytes>>(TokioExecutor::new(), io)
554        .await
555        .map_err(|error| std::io::Error::other(error))?;
556
557    // Drive the connection I/O in the background. Because `sender` is
558    // `Clone` (it's a channel handle), we can cache one clone in the pool
559    // and use another to send this request. The connection driver stays
560    // alive as long as ANY clone of the sender is live — so once we cache
561    // it in the pool, the connection persists across many requests until
562    // the pool evicts it.
563    tokio::spawn(async move {
564        let _ = connection.await;
565    });
566
567    // Cache the sender BEFORE sending this request. Even if this request
568    // fails, the sender is still valid for future retries.
569    pool.store_h2(host, port, sender.clone()).await;
570
571    send_over_h2(sender, host, request_target, request, measurement).await
572}
573
574/// Send a single HTTP/2 request over an already-established `SendRequest`.
575/// Used both on the warm path (cached sender from the pool) and internally
576/// from `exchange_over_h2` (fresh sender we just handshaked).
577async fn send_over_h2(
578    mut sender: crate::engine::pool::PooledH2Sender,
579    host: &str,
580    request_target: &str,
581    request: &IncomingRequest,
582    measurement: &mut ProxyMeasurement,
583) -> std::io::Result<UpstreamResponse> {
584    use http::Request as HttpRequest;
585    use http_body_util::{BodyExt, Full};
586    use hyper::body::Bytes;
587
588    // Build the h2 request. For HTTP/2 the URI must include scheme + authority
589    // so hyper can populate the `:scheme`/`:authority` pseudo-headers; we do NOT
590    // forward the client's `Host` header (h2 has no Host header — it's the
591    // `:authority` pseudo-header instead).
592    let uri: http::Uri = format!("https://{host}{request_target}")
593        .parse()
594        .map_err(|error: http::uri::InvalidUri| std::io::Error::other(error))?;
595
596    let method = http::Method::from_bytes(request.method.as_bytes())
597        .map_err(|error| std::io::Error::other(error))?;
598
599    let mut builder = HttpRequest::builder().method(method).uri(uri);
600
601    for (name, value) in &request.headers {
602        if header_is_hop_by_hop_or_h2_incompatible(name) {
603            continue;
604        }
605        builder = builder.header(name.as_str(), value.as_str());
606    }
607
608    let body = Full::new(Bytes::from(request.body.clone()));
609    let req = builder
610        .body(body)
611        .map_err(|error| std::io::Error::other(error))?;
612
613    let ttfb_start = Instant::now();
614    let response = sender
615        .send_request(req)
616        .await
617        .map_err(|error| std::io::Error::other(error))?;
618    measurement.record_ttfb_ms(ttfb_start.elapsed().as_millis() as u64);
619
620    let status_code = response.status().as_u16();
621    let status_text = response
622        .status()
623        .canonical_reason()
624        .unwrap_or("")
625        .to_string();
626
627    let mut response_headers: Vec<(String, String)> = Vec::new();
628    let mut redirect_location: Option<String> = None;
629    for (name, value) in response.headers() {
630        let value_str = match value.to_str() {
631            Ok(text) => text.to_string(),
632            Err(_) => continue,
633        };
634        if name.as_str().eq_ignore_ascii_case("location") {
635            redirect_location = Some(value_str.clone());
636        }
637        response_headers.push((name.as_str().to_string(), value_str));
638    }
639
640    let download_start = Instant::now();
641    let body_bytes = response
642        .into_body()
643        .collect()
644        .await
645        .map_err(|error| std::io::Error::other(error))?
646        .to_bytes();
647    measurement.record_download_ms(download_start.elapsed().as_millis() as u64);
648
649    Ok(UpstreamResponse {
650        status_code,
651        status_text,
652        protocol_version: "HTTP/2".to_string(),
653        response_headers,
654        redirect_location,
655        body: body_bytes.to_vec(),
656    })
657}
658
659/// Headers we should not forward across HTTP/2. Some are hop-by-hop and
660/// per-RFC 7230 not meaningful end-to-end; some (like `host`) are replaced by
661/// h2 pseudo-headers; some (like `transfer-encoding`) are outright forbidden
662/// by RFC 7540 §8.1.2.2.
663fn header_is_hop_by_hop_or_h2_incompatible(name: &str) -> bool {
664    matches!(
665        name.to_ascii_lowercase().as_str(),
666        "host"
667            | "connection"
668            | "proxy-connection"
669            | "keep-alive"
670            | "transfer-encoding"
671            | "te"
672            | "trailer"
673            | "upgrade"
674            | "content-length"
675    )
676}
677
678fn serialize_request(
679    method: &str,
680    request_target: &str,
681    host: &str,
682    headers: &[(String, String)],
683    body: &[u8],
684) -> Vec<u8> {
685    let mut out = format!("{method} {request_target} HTTP/1.1\r\n").into_bytes();
686    out.extend_from_slice(format!("Host: {host}\r\n").as_bytes());
687    out.extend_from_slice(b"Connection: close\r\n");
688
689    for (name, value) in headers {
690        if name.eq_ignore_ascii_case("host")
691            || name.eq_ignore_ascii_case("connection")
692            || name.eq_ignore_ascii_case("proxy-connection")
693            || name.eq_ignore_ascii_case("content-length")
694        {
695            continue;
696        }
697        out.extend_from_slice(name.as_bytes());
698        out.extend_from_slice(b": ");
699        out.extend_from_slice(value.as_bytes());
700        out.extend_from_slice(b"\r\n");
701    }
702
703    if !body.is_empty() {
704        out.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
705    }
706
707    out.extend_from_slice(b"\r\n");
708    out.extend_from_slice(body);
709    out
710}
711
712fn parse_upstream_response(buffer: &[u8]) -> std::io::Result<UpstreamResponse> {
713    let header_end = find_header_end(buffer).ok_or_else(|| {
714        std::io::Error::new(std::io::ErrorKind::InvalidData, "missing response headers")
715    })?;
716
717    let header_bytes = &buffer[..header_end];
718    let body_bytes = &buffer[header_end + 4..];
719
720    let header_text = std::str::from_utf8(header_bytes)
721        .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "response headers not utf8"))?;
722
723    let mut lines = header_text.split("\r\n");
724    let status_line = lines
725        .next()
726        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "missing status line"))?;
727
728    let mut parts = status_line.splitn(3, ' ');
729    let protocol_version = parts.next().unwrap_or("HTTP/1.1").to_string();
730    let status_code = parts
731        .next()
732        .and_then(|value| value.parse::<u16>().ok())
733        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "missing status code"))?;
734    let status_text = parts.next().unwrap_or("").trim().to_string();
735
736    let mut response_headers: Vec<(String, String)> = Vec::new();
737    let mut is_chunked = false;
738    let mut redirect_location: Option<String> = None;
739
740    for line in lines {
741        let Some((name, value)) = line.split_once(':') else {
742            continue;
743        };
744        let name = name.trim();
745        let value = value.trim();
746        if name.eq_ignore_ascii_case("transfer-encoding")
747            && value.to_ascii_lowercase().contains("chunked")
748        {
749            is_chunked = true;
750        }
751        if name.eq_ignore_ascii_case("location") {
752            redirect_location = Some(value.to_string());
753        }
754        response_headers.push((name.to_string(), value.to_string()));
755    }
756
757    let body = if is_chunked {
758        decode_chunked(body_bytes)?
759    } else {
760        body_bytes.to_vec()
761    };
762
763    Ok(UpstreamResponse {
764        status_code,
765        status_text,
766        protocol_version,
767        response_headers,
768        redirect_location,
769        body,
770    })
771}
772
773fn decode_chunked(input: &[u8]) -> std::io::Result<Vec<u8>> {
774    let mut out = Vec::with_capacity(input.len());
775    let mut cursor = 0usize;
776
777    loop {
778        let line_end = match find_crlf(&input[cursor..]) {
779            Some(pos) => cursor + pos,
780            None => {
781                return Err(std::io::Error::new(
782                    std::io::ErrorKind::InvalidData,
783                    "unterminated chunk size line",
784                ));
785            }
786        };
787
788        let size_line = std::str::from_utf8(&input[cursor..line_end])
789            .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "chunk size not utf8"))?;
790        let size_hex = size_line.split(';').next().unwrap_or("").trim();
791        let chunk_size = usize::from_str_radix(size_hex, 16)
792            .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid chunk size"))?;
793
794        cursor = line_end + 2;
795
796        if chunk_size == 0 {
797            break;
798        }
799
800        if cursor + chunk_size > input.len() {
801            return Err(std::io::Error::new(
802                std::io::ErrorKind::InvalidData,
803                "chunk body exceeds buffer",
804            ));
805        }
806
807        out.extend_from_slice(&input[cursor..cursor + chunk_size]);
808        cursor += chunk_size;
809
810        if input.get(cursor..cursor + 2) != Some(b"\r\n") {
811            return Err(std::io::Error::new(
812                std::io::ErrorKind::InvalidData,
813                "missing CRLF after chunk",
814            ));
815        }
816        cursor += 2;
817    }
818
819    Ok(out)
820}
821
822fn find_crlf(buffer: &[u8]) -> Option<usize> {
823    buffer.windows(2).position(|window| window == b"\r\n")
824}
825
826fn build_tls_config() -> ClientConfig {
827    let roots = RootCertStore::from_iter(TLS_SERVER_ROOTS.iter().cloned());
828    let mut config = ClientConfig::builder()
829        .with_root_certificates(roots)
830        .with_no_client_auth();
831    config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
832    config
833}
834
835fn build_tls_connector() -> TlsConnector {
836    TlsConnector::from(Arc::new(build_tls_config()))
837}
838
839fn finish_report(measurement: &ProxyMeasurement, total_start: Instant) -> TimingReport {
840    let mut report = measurement.finish_report();
841    report.total_ms = total_start.elapsed().as_millis() as u64;
842    report
843}
844
845async fn read_http_request(stream: &mut TcpStream) -> std::io::Result<Option<IncomingRequest>> {
846    let mut buffer = Vec::new();
847    let mut chunk = [0_u8; 2048];
848
849    loop {
850        let bytes_read = stream.read(&mut chunk).await?;
851        if bytes_read == 0 {
852            if buffer.is_empty() {
853                return Ok(None);
854            }
855            break;
856        }
857
858        buffer.extend_from_slice(&chunk[..bytes_read]);
859
860        if let Some(header_end) = find_header_end(&buffer) {
861            let content_length = parse_content_length(&buffer[..header_end]).unwrap_or(0);
862            let total_length = header_end + 4 + content_length;
863
864            while buffer.len() < total_length {
865                let bytes_read = stream.read(&mut chunk).await?;
866                if bytes_read == 0 {
867                    break;
868                }
869                buffer.extend_from_slice(&chunk[..bytes_read]);
870            }
871
872            return parse_http_request(&buffer).map(Some);
873        }
874    }
875
876    parse_http_request(&buffer).map(Some)
877}
878
879fn parse_http_request(buffer: &[u8]) -> std::io::Result<IncomingRequest> {
880    let header_end = find_header_end(buffer)
881        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "missing request headers"))?;
882    let header_bytes = &buffer[..header_end];
883    let body = buffer[header_end + 4..].to_vec();
884    let header_text = std::str::from_utf8(header_bytes)
885        .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "request headers not utf8"))?;
886    let mut lines = header_text.split("\r\n");
887    let request_line = lines
888        .next()
889        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "missing request line"))?;
890    let mut parts = request_line.split_whitespace();
891    let method = parts
892        .next()
893        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "missing method"))?;
894    let path = parts
895        .next()
896        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "missing path"))?;
897    let version = parts.next().unwrap_or("HTTP/1.1");
898
899    let headers = lines
900        .filter_map(|line| {
901            let (name, value) = line.split_once(':')?;
902            Some((name.trim().to_string(), value.trim().to_string()))
903        })
904        .collect::<Vec<_>>();
905
906    Ok(IncomingRequest {
907        method: method.to_string(),
908        path: normalize_request_path(path),
909        version: version.to_string(),
910        headers,
911        body,
912    })
913}
914
915fn normalize_request_path(path: &str) -> String {
916    if let Ok(url) = Url::parse(path) {
917        let mut normalized = url.path().to_string();
918        if normalized.is_empty() {
919            normalized.push('/');
920        }
921        if let Some(query) = url.query() {
922            normalized.push('?');
923            normalized.push_str(query);
924        }
925        normalized
926    } else if path.is_empty() {
927        "/".to_string()
928    } else {
929        path.to_string()
930    }
931}
932
933fn build_upstream_url(upstream: &Url, request_path: &str) -> std::result::Result<Url, url::ParseError> {
934    let request_path = normalize_request_path(request_path);
935    let (path_only, query) = match request_path.split_once('?') {
936        Some((path, query)) => (path, Some(query)),
937        None => (request_path.as_str(), None),
938    };
939    let request_without_root = path_only.trim_start_matches('/');
940    let base_without_trailing = upstream.path().trim_end_matches('/');
941
942    let combined_path = if base_without_trailing.is_empty() || base_without_trailing == "/" {
943        format!("/{}", request_without_root)
944    } else if request_without_root.is_empty() {
945        base_without_trailing.to_string()
946    } else {
947        format!("{base_without_trailing}/{request_without_root}")
948    };
949
950    let mut url = upstream.clone();
951    url.set_path(if combined_path.is_empty() { "/" } else { &combined_path });
952    url.set_query(query);
953
954    Ok(url)
955}
956
957fn build_request_target(url: &Url) -> String {
958    let mut target = url.path().to_string();
959    if target.is_empty() {
960        target.push('/');
961    }
962
963    if let Some(query) = url.query() {
964        target.push('?');
965        target.push_str(query);
966    }
967
968    target
969}
970
971fn build_raw_response(
972    status_code: u16,
973    status_text: &str,
974    headers: &[(String, String)],
975    body: &[u8],
976) -> Vec<u8> {
977    let mut response = format!("HTTP/1.1 {status_code} {status_text}\r\n").into_bytes();
978
979    for (name, value) in headers {
980        if name.eq_ignore_ascii_case("connection")
981            || name.eq_ignore_ascii_case("transfer-encoding")
982            || name.eq_ignore_ascii_case("content-length")
983        {
984            continue;
985        }
986
987        response.extend_from_slice(name.as_bytes());
988        response.extend_from_slice(b": ");
989        response.extend_from_slice(value.as_bytes());
990        response.extend_from_slice(b"\r\n");
991    }
992
993    response
994        .extend_from_slice(format!("Content-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes());
995    response.extend_from_slice(body);
996    response
997}
998
999fn success_exchange(
1000    target_url: &Url,
1001    request: &IncomingRequest,
1002    report: TimingReport,
1003    request_headers: Vec<(String, String)>,
1004    response_headers: Vec<(String, String)>,
1005    protocol_version: Option<String>,
1006    redirect_location: Option<String>,
1007    tls: Option<TlsReport>,
1008    metadata: ResponseMetadata,
1009) -> CapturedExchange {
1010    CapturedExchange {
1011        target: target_url.to_string(),
1012        request_host: target_url.host_str().unwrap_or("n/a").to_string(),
1013        request_method: request.method.clone(),
1014        request_path: normalize_request_path(&request.path),
1015        request_headers,
1016        response_headers,
1017        protocol_version,
1018        redirect_location,
1019        response_status_code: metadata.status_code,
1020        response_status_text: metadata.status_text,
1021        response_bytes: metadata.bytes,
1022        report,
1023        tls,
1024        error_message: None,
1025        created_at_ms: current_time_ms().unwrap_or_default(),
1026        connection_reuse: "new".to_string(),
1027    }
1028}
1029
1030fn error_exchange(
1031    upstream: &Url,
1032    request: &IncomingRequest,
1033    report: TimingReport,
1034    tls: Option<TlsReport>,
1035    error_message: String,
1036) -> CapturedExchange {
1037    let body = error_message.clone();
1038    CapturedExchange {
1039        target: upstream.to_string(),
1040        request_host: upstream.host_str().unwrap_or("n/a").to_string(),
1041        request_method: request.method.clone(),
1042        request_path: normalize_request_path(&request.path),
1043        request_headers: request.headers.clone(),
1044        response_headers: Vec::new(),
1045        protocol_version: None,
1046        redirect_location: None,
1047        response_status_code: Some(502),
1048        response_status_text: Some("Bad Gateway".to_string()),
1049        response_bytes: body.len() as u64,
1050        report,
1051        tls,
1052        error_message: Some(error_message),
1053        created_at_ms: current_time_ms().unwrap_or_default(),
1054        connection_reuse: "new".to_string(),
1055    }
1056}
1057
1058fn tls_report_for_failure(is_https: bool, error_message: String) -> Option<TlsReport> {
1059    if is_https {
1060        Some(report_for_https_failure(error_message))
1061    } else {
1062        Some(report_for_http())
1063    }
1064}
1065
1066fn bad_gateway_response(message: &str) -> Vec<u8> {
1067    let body = message.as_bytes();
1068    format!(
1069        "HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1070        body.len(),
1071        message
1072    )
1073    .into_bytes()
1074}
1075
1076fn parse_content_length(header_bytes: &[u8]) -> Option<usize> {
1077    let header_text = std::str::from_utf8(header_bytes).ok()?;
1078    header_text
1079        .lines()
1080        .find_map(|line| {
1081            let (name, value) = line.split_once(':')?;
1082            if name.eq_ignore_ascii_case("content-length") {
1083                value.trim().parse::<usize>().ok()
1084            } else {
1085                None
1086            }
1087        })
1088}
1089
1090fn find_header_end(buffer: &[u8]) -> Option<usize> {
1091    buffer.windows(4).position(|window| window == b"\r\n\r\n")
1092}
1093
1094fn current_time_ms() -> Result<i64> {
1095    let now = SystemTime::now().duration_since(UNIX_EPOCH)?;
1096    Ok(now.as_millis() as i64)
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101    use super::{
1102        build_raw_response, build_tls_config, build_upstream_url, decode_chunked,
1103        normalize_request_path, parse_http_request, parse_upstream_response, serialize_request,
1104    };
1105    use url::Url;
1106
1107    #[test]
1108    fn tls_config_advertises_h2_then_http1_alpn() {
1109        let config = build_tls_config();
1110        assert_eq!(
1111            config.alpn_protocols,
1112            vec![b"h2".to_vec(), b"http/1.1".to_vec()],
1113            "capture must advertise h2 first so servers pick it (and we then reject cleanly) \
1114             instead of silently downgrading to http/1.1 and reporting wrong numbers",
1115        );
1116    }
1117
1118    #[test]
1119    fn normalize_request_path_extracts_path_from_absolute_url() {
1120        assert_eq!(
1121            normalize_request_path("https://example.com/api/health?full=1"),
1122            "/api/health?full=1"
1123        );
1124    }
1125
1126    #[test]
1127    fn build_upstream_url_preserves_base_prefix() {
1128        let upstream = Url::parse("https://example.com/api").expect("url should parse");
1129
1130        assert_eq!(
1131            build_upstream_url(&upstream, "/health").expect("url should build").as_str(),
1132            "https://example.com/api/health"
1133        );
1134    }
1135
1136    #[test]
1137    fn build_upstream_url_preserves_query_without_encoding_it_into_path() {
1138        let upstream = Url::parse("http://127.0.0.1:8000").expect("url should parse");
1139
1140        assert_eq!(
1141            build_upstream_url(&upstream, "/mlb/run-conversion?timeframe=season")
1142                .expect("url should build")
1143                .as_str(),
1144            "http://127.0.0.1:8000/mlb/run-conversion?timeframe=season"
1145        );
1146    }
1147
1148    #[test]
1149    fn parse_http_request_reads_method_path_and_body() {
1150        let request = b"POST /users HTTP/1.1\r\nHost: localhost\r\nContent-Length: 7\r\n\r\nhello=1";
1151        let parsed = parse_http_request(request).expect("request should parse");
1152
1153        assert_eq!(parsed.method, "POST");
1154        assert_eq!(parsed.path, "/users");
1155        assert_eq!(parsed.version, "HTTP/1.1");
1156        assert_eq!(parsed.body, b"hello=1");
1157    }
1158
1159    #[test]
1160    fn build_raw_response_preserves_content_encoding_and_strips_hop_headers() {
1161        let headers = vec![
1162            ("Content-Type".to_string(), "text/html; charset=utf-8".to_string()),
1163            ("Content-Encoding".to_string(), "gzip".to_string()),
1164            ("Transfer-Encoding".to_string(), "chunked".to_string()),
1165            ("Connection".to_string(), "keep-alive".to_string()),
1166        ];
1167
1168        let response = build_raw_response(200, "OK", &headers, b"abc");
1169        let response_text = String::from_utf8_lossy(&response).to_ascii_lowercase();
1170
1171        assert!(response_text.contains("content-encoding: gzip\r\n"));
1172        assert!(response_text.contains("content-type: text/html; charset=utf-8\r\n"));
1173        assert!(response_text.contains("content-length: 3\r\n"));
1174        assert!(!response_text.contains("transfer-encoding:"));
1175        assert!(response_text.ends_with("\r\n\r\nabc"));
1176    }
1177
1178    #[test]
1179    fn serialize_request_forces_close_and_strips_hop_headers() {
1180        let bytes = serialize_request(
1181            "POST",
1182            "/api/users",
1183            "example.com",
1184            &[
1185                ("Content-Type".to_string(), "application/json".to_string()),
1186                ("Host".to_string(), "old-host".to_string()),
1187                ("Connection".to_string(), "keep-alive".to_string()),
1188                ("Content-Length".to_string(), "999".to_string()),
1189            ],
1190            b"{}",
1191        );
1192
1193        let text = String::from_utf8_lossy(&bytes);
1194        assert!(text.starts_with("POST /api/users HTTP/1.1\r\n"));
1195        assert!(text.contains("Host: example.com\r\n"));
1196        assert!(text.contains("Connection: close\r\n"));
1197        assert!(text.contains("Content-Length: 2\r\n"));
1198        assert!(!text.to_ascii_lowercase().contains("host: old-host"));
1199        assert!(text.ends_with("\r\n\r\n{}"));
1200    }
1201
1202    #[test]
1203    fn parse_upstream_response_reads_status_and_body() {
1204        let raw = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhello world";
1205        let response = parse_upstream_response(raw).expect("should parse");
1206
1207        assert_eq!(response.status_code, 200);
1208        assert_eq!(response.status_text, "OK");
1209        assert_eq!(response.protocol_version, "HTTP/1.1");
1210        assert_eq!(response.body, b"hello world");
1211        assert!(response
1212            .response_headers
1213            .iter()
1214            .any(|(name, value)| name.eq_ignore_ascii_case("content-type") && value == "text/plain"));
1215    }
1216
1217    #[test]
1218    fn parse_upstream_response_decodes_chunked_body() {
1219        let raw = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n";
1220        let response = parse_upstream_response(raw).expect("should parse");
1221
1222        assert_eq!(response.body, b"hello world");
1223    }
1224
1225    #[test]
1226    fn parse_upstream_response_captures_redirect_location() {
1227        let raw = b"HTTP/1.1 302 Found\r\nLocation: /next\r\nContent-Length: 0\r\n\r\n";
1228        let response = parse_upstream_response(raw).expect("should parse");
1229
1230        assert_eq!(response.status_code, 302);
1231        assert_eq!(response.redirect_location.as_deref(), Some("/next"));
1232    }
1233
1234    #[test]
1235    fn decode_chunked_handles_extension_after_size() {
1236        let raw = b"3;name=value\r\nabc\r\n0\r\n\r\n";
1237        assert_eq!(decode_chunked(raw).expect("should decode"), b"abc");
1238    }
1239
1240    #[test]
1241    fn decode_chunked_rejects_missing_crlf() {
1242        let raw = b"3\r\nabcXX0\r\n\r\n";
1243        assert!(decode_chunked(raw).is_err());
1244    }
1245
1246    #[test]
1247    fn h2_header_filter_strips_incompatible_and_hop_by_hop_headers() {
1248        use super::header_is_hop_by_hop_or_h2_incompatible as filter;
1249
1250        // Forbidden in HTTP/2 per RFC 7540 §8.1.2.2
1251        assert!(filter("connection"));
1252        assert!(filter("Connection"));
1253        assert!(filter("keep-alive"));
1254        assert!(filter("proxy-connection"));
1255        assert!(filter("transfer-encoding"));
1256        assert!(filter("upgrade"));
1257        assert!(filter("TE"));
1258
1259        // Host is replaced by :authority pseudo-header in h2
1260        assert!(filter("host"));
1261
1262        // Content-length is derived from the body in h2
1263        assert!(filter("content-length"));
1264
1265        // These should pass through
1266        assert!(!filter("content-type"));
1267        assert!(!filter("accept"));
1268        assert!(!filter("authorization"));
1269        assert!(!filter("user-agent"));
1270        assert!(!filter("x-custom-header"));
1271    }
1272}