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