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