1- //! Local HTTP forward proxy: explicit HTTP requests and **CONNECT** tunnels (TLS end-to-end;
2- //! payload is not decrypted). HTTP traffic is forwarded with `reqwest` and fully captured.
1+ //! Local HTTP forward proxy: explicit HTTP requests and **CONNECT** tunnels.
2+ //! With `--decrypt`: TLS MITM via per-host certificates signed by a local root CA.
3+ //! Without `--decrypt`: CONNECT is tunneled end-to-end (no decryption).
34
45use std:: convert:: Infallible ;
56use std:: io:: { self , IsTerminal } ;
@@ -22,8 +23,10 @@ use tokio::net::{TcpListener, TcpStream};
2223use crate :: capture:: model:: { CaptureRecord , HttpMessageSnapshot } ;
2324use crate :: capture:: redact;
2425use crate :: capture:: session:: SessionManager ;
25- use crate :: error:: { Error , Result } ;
2626use crate :: config:: AppConfig ;
27+ use crate :: error:: { Error , Result } ;
28+ use crate :: tls:: ca:: CertificateAuthority ;
29+ use crate :: tls:: cert:: make_tls_acceptor_for_host;
2730
2831const HOP_BY_HOP : & [ & str ] = & [
2932 "connection" ,
@@ -43,9 +46,30 @@ pub struct ProxyState {
4346 pub unsafe_show_secrets : bool ,
4447 /// Spinner + message when stdout is a TTY; otherwise plain `println!` per capture.
4548 pub progress : Option < ProgressBar > ,
49+ /// When set, CONNECT is intercepted with TLS MITM instead of tunneled.
50+ pub ca : Option < Arc < CertificateAuthority > > ,
51+ /// Live feed for TUI mode.
52+ pub capture_tx : Option < tokio:: sync:: broadcast:: Sender < CaptureRecord > > ,
4653}
4754
4855impl ProxyState {
56+ pub fn persist_and_broadcast (
57+ & self ,
58+ capture : CaptureRecord ,
59+ count : & AtomicU64 ,
60+ ) {
61+ let summary = format ! ( "{} {}" , capture. method, capture. url) ;
62+ let ms = capture. duration_ms . unwrap_or ( 0 ) ;
63+ if let Err ( e) = self . session . persist_capture ( capture. clone ( ) ) {
64+ tracing:: error!( ?e, "persist capture failed" ) ;
65+ }
66+ if let Some ( ref tx) = self . capture_tx {
67+ let _ = tx. send ( capture) ;
68+ }
69+ let n = count. fetch_add ( 1 , Ordering :: Relaxed ) + 1 ;
70+ self . notify_capture ( n, & summary, ms) ;
71+ }
72+
4973 pub fn notify_capture ( & self , n : u64 , summary : & str , ms : u64 ) {
5074 let line = format ! ( "{summary} · {ms}ms" ) ;
5175 if let Some ( pb) = & self . progress {
@@ -202,7 +226,8 @@ async fn handle_connect(
202226) -> std:: result:: Result < Response < Full < Bytes > > , Infallible > {
203227 let started = Instant :: now ( ) ;
204228 let created_at = Utc :: now ( ) ;
205- let authority = match req. uri ( ) . authority ( ) . map ( |a| a. as_str ( ) ) {
229+ let authority_owned = req. uri ( ) . authority ( ) . map ( |a| a. as_str ( ) . to_string ( ) ) ;
230+ let authority = match authority_owned. as_deref ( ) {
206231 Some ( a) if !a. is_empty ( ) => a,
207232 _ => {
208233 return Ok ( Response :: builder ( )
@@ -263,7 +288,7 @@ async fn handle_connect(
263288 created_at,
264289 format ! ( "CONNECT upstream: {e}" ) ,
265290 ) ;
266- let _ = state. session . persist_capture ( rec) ;
291+ state. persist_and_broadcast ( rec, & count ) ;
267292 return Ok ( Response :: builder ( )
268293 . status ( StatusCode :: BAD_GATEWAY )
269294 . body ( Full :: new ( Bytes :: from ( format ! ( "CONNECT failed: {e}" ) ) ) )
@@ -304,31 +329,100 @@ async fn handle_connect(
304329 status : Some ( 200 ) ,
305330 duration_ms : Some ( connect_ms) ,
306331 error : None ,
332+ ws_frames : vec ! [ ] ,
307333 } ;
308- if let Err ( e ) = state. session . persist_capture ( capture) {
309- tracing :: error! ( ?e , "persist CONNECT capture failed" ) ;
310- }
311- let n = count . fetch_add ( 1 , Ordering :: Relaxed ) + 1 ;
312- state . notify_capture ( n , & format ! ( "CONNECT {target}" ) , connect_ms ) ;
313-
314- tokio :: task :: spawn ( async move {
315- match upgrade . await {
316- Ok ( up ) => {
317- // Client side: hyper `Upgraded` via TokioIo. Upstream: raw `TcpStream` (tokio I/O).
318- let mut client = TokioIo :: new ( up) ;
319- let mut server = upstream ;
320- let _ = tokio :: io :: copy_bidirectional ( & mut client , & mut server ) . await ;
334+ state. persist_and_broadcast ( capture, & count ) ;
335+
336+ if let Some ( ref ca ) = state . ca {
337+ let ca = Arc :: clone ( ca ) ;
338+ let host_for_cert = authority . split ( ':' ) . next ( ) . unwrap_or ( authority ) . to_string ( ) ;
339+ let state2 = Arc :: clone ( & state ) ;
340+ let count2 = Arc :: clone ( & count ) ;
341+ tokio :: task :: spawn ( async move {
342+ match upgrade . await {
343+ Ok ( up ) => {
344+ handle_mitm ( up, upstream , host_for_cert , ca , state2 , count2 ) . await ;
345+ }
346+ Err ( e ) => tracing :: debug! ( "CONNECT upgrade dropped: {e}" ) ,
321347 }
322- Err ( e) => tracing:: debug!( "CONNECT upgrade dropped: {e}" ) ,
323- }
324- } ) ;
348+ } ) ;
349+ } else {
350+ tokio:: task:: spawn ( async move {
351+ match upgrade. await {
352+ Ok ( up) => {
353+ let mut client = TokioIo :: new ( up) ;
354+ let mut server = upstream;
355+ let _ = tokio:: io:: copy_bidirectional ( & mut client, & mut server) . await ;
356+ }
357+ Err ( e) => tracing:: debug!( "CONNECT upgrade dropped: {e}" ) ,
358+ }
359+ } ) ;
360+ }
325361
326362 Ok ( Response :: builder ( )
327363 . status ( StatusCode :: OK )
328364 . body ( Full :: new ( Bytes :: new ( ) ) )
329365 . unwrap ( ) )
330366}
331367
368+ /// After TLS termination the inner HTTP request has a relative URI (e.g. `/api/v1/foo`).
369+ /// We rewrite it to an absolute `https://host/…` URL so our forwarding logic works.
370+ fn rewrite_connect_request_uri ( req : & mut Request < Incoming > , host : & str ) {
371+ let path = req
372+ . uri ( )
373+ . path_and_query ( )
374+ . map ( |pq| pq. as_str ( ) )
375+ . unwrap_or ( "/" ) ;
376+ if let Ok ( new_uri) = format ! ( "https://{host}{path}" ) . parse :: < hyper:: Uri > ( ) {
377+ * req. uri_mut ( ) = new_uri;
378+ }
379+ }
380+
381+ /// MITM handler: terminate TLS with the client using a per-host cert, read plaintext
382+ /// HTTP inside the tunnel, forward to the real server via reqwest, capture everything.
383+ async fn handle_mitm (
384+ upgraded : hyper:: upgrade:: Upgraded ,
385+ _upstream_tcp : TcpStream ,
386+ hostname : String ,
387+ ca : Arc < CertificateAuthority > ,
388+ state : Arc < ProxyState > ,
389+ count : Arc < AtomicU64 > ,
390+ ) {
391+ let acceptor = match make_tls_acceptor_for_host ( & ca, & hostname) {
392+ Ok ( a) => a,
393+ Err ( e) => {
394+ tracing:: debug!( "TLS acceptor for {hostname}: {e}" ) ;
395+ return ;
396+ }
397+ } ;
398+
399+ let client_io = TokioIo :: new ( upgraded) ;
400+ let tls_stream = match acceptor. accept ( client_io) . await {
401+ Ok ( s) => s,
402+ Err ( e) => {
403+ tracing:: debug!( "TLS accept for {hostname}: {e}" ) ;
404+ return ;
405+ }
406+ } ;
407+
408+ let tls_io = TokioIo :: new ( tls_stream) ;
409+
410+ let service = hyper:: service:: service_fn ( move |mut req : Request < Incoming > | {
411+ let state = Arc :: clone ( & state) ;
412+ let count = Arc :: clone ( & count) ;
413+ let host = hostname. clone ( ) ;
414+ async move {
415+ rewrite_connect_request_uri ( & mut req, & host) ;
416+ handle_forward ( req, state, count) . await
417+ }
418+ } ) ;
419+
420+ let _ = ConnBuilder :: new ( TokioExecutor :: new ( ) )
421+ . serve_connection ( tls_io, service)
422+ . await ;
423+ }
424+
425+ /// Top-level handler dispatching CONNECT vs plain HTTP.
332426async fn handle_request (
333427 req : Request < Incoming > ,
334428 state : Arc < ProxyState > ,
@@ -337,7 +431,16 @@ async fn handle_request(
337431 if req. method ( ) == hyper:: Method :: CONNECT {
338432 return handle_connect ( req, state, count) . await ;
339433 }
434+ handle_forward ( req, state, count) . await
435+ }
340436
437+ /// Forward a non-CONNECT HTTP request to the upstream and capture it.
438+ /// Called both from `handle_request` (plain HTTP) and from the MITM TLS service.
439+ async fn handle_forward (
440+ req : Request < Incoming > ,
441+ state : Arc < ProxyState > ,
442+ count : Arc < AtomicU64 > ,
443+ ) -> std:: result:: Result < Response < Full < Bytes > > , Infallible > {
341444 let started = Instant :: now ( ) ;
342445 let created_at = Utc :: now ( ) ;
343446 let url = match build_target_url ( & req) {
@@ -409,7 +512,7 @@ async fn handle_request(
409512 created_at,
410513 e. to_string ( ) ,
411514 ) ;
412- let _ = state. session . persist_capture ( rec) ;
515+ state. persist_and_broadcast ( rec, & count ) ;
413516 return Ok ( Response :: builder ( )
414517 . status ( StatusCode :: BAD_GATEWAY )
415518 . body ( Full :: new ( Bytes :: from ( format ! ( "request build error: {e}" ) ) ) )
@@ -452,12 +555,9 @@ async fn handle_request(
452555 status : st,
453556 duration_ms : Some ( elapsed) ,
454557 error : None ,
558+ ws_frames : vec ! [ ] ,
455559 } ;
456- if let Err ( e) = state. session . persist_capture ( capture) {
457- tracing:: error!( ?e, "persist capture failed" ) ;
458- }
459- let n = count. fetch_add ( 1 , Ordering :: Relaxed ) + 1 ;
460- state. notify_capture ( n, & format ! ( "{method} {url}" ) , elapsed) ;
560+ state. persist_and_broadcast ( capture, & count) ;
461561
462562 let code = StatusCode :: from_u16 ( status) . unwrap_or ( StatusCode :: BAD_GATEWAY ) ;
463563 let mut res = Response :: builder ( ) . status ( code) ;
@@ -487,7 +587,7 @@ async fn handle_request(
487587 created_at,
488588 e. to_string ( ) ,
489589 ) ;
490- let _ = state. session . persist_capture ( rec) ;
590+ state. persist_and_broadcast ( rec, & count ) ;
491591 Ok ( Response :: builder ( )
492592 . status ( StatusCode :: BAD_GATEWAY )
493593 . body ( Full :: new ( Bytes :: from ( format ! ( "response body error: {e}" ) ) ) )
@@ -505,7 +605,7 @@ async fn handle_request(
505605 created_at,
506606 e. to_string ( ) ,
507607 ) ;
508- let _ = state. session . persist_capture ( rec) ;
608+ state. persist_and_broadcast ( rec, & count ) ;
509609 Ok ( Response :: builder ( )
510610 . status ( StatusCode :: BAD_GATEWAY )
511611 . body ( Full :: new ( Bytes :: from ( format ! ( "upstream error: {e}" ) ) ) )
@@ -534,6 +634,7 @@ fn error_capture(
534634 status : None ,
535635 duration_ms : Some ( started. elapsed ( ) . as_millis ( ) as u64 ) ,
536636 error : Some ( err) ,
637+ ws_frames : vec ! [ ] ,
537638 }
538639}
539640
@@ -542,6 +643,8 @@ pub async fn run_proxy(
542643 session : SessionManager ,
543644 bind : SocketAddr ,
544645 unsafe_show_secrets : bool ,
646+ ca : Option < CertificateAuthority > ,
647+ capture_tx : Option < tokio:: sync:: broadcast:: Sender < CaptureRecord > > ,
545648) -> Result < ( ) > {
546649 let client = reqwest:: Client :: builder ( )
547650 . timeout ( Duration :: from_secs ( cfg. timeout_secs ) )
@@ -562,12 +665,15 @@ pub async fn run_proxy(
562665 None
563666 } ;
564667
668+ let decrypt = ca. is_some ( ) ;
565669 let state = Arc :: new ( ProxyState {
566670 cfg : cfg. clone ( ) ,
567671 session : Arc :: new ( session) ,
568672 client,
569673 unsafe_show_secrets,
570674 progress,
675+ ca : ca. map ( Arc :: new) ,
676+ capture_tx,
571677 } ) ;
572678
573679 let listener = TcpListener :: bind ( bind) . await ?;
@@ -579,11 +685,19 @@ pub async fn run_proxy(
579685 "proxy listening on" . dimmed( ) ,
580686 bind. to_string( ) . yellow( )
581687 ) ;
582- eprintln ! (
583- "{}" ,
584- "HTTP + HTTPS (CONNECT tunnel). TLS inside CONNECT is not decrypted."
585- . dimmed( )
586- ) ;
688+ if decrypt {
689+ eprintln ! (
690+ "{}" ,
691+ "HTTP + HTTPS (TLS MITM decryption enabled). HTTPS traffic will be captured in plaintext."
692+ . dimmed( )
693+ ) ;
694+ } else {
695+ eprintln ! (
696+ "{}" ,
697+ "HTTP + HTTPS (CONNECT tunnel). TLS inside CONNECT is not decrypted."
698+ . dimmed( )
699+ ) ;
700+ }
587701 eprintln ! (
588702 "{}" ,
589703 "Press Ctrl+C to stop capture." . dimmed( )
0 commit comments