Skip to content

Commit a30adec

Browse files
committed
Add live TUI, structured filters, WebSocket capture, and session diffs
Made-with: Cursor
1 parent 793fbfa commit a30adec

25 files changed

Lines changed: 2832 additions & 68 deletions

Cargo.lock

Lines changed: 1461 additions & 32 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ url = "2.5"
4141
uuid = { version = "1.11", features = ["v4", "serde"] }
4242
futures-util = "0.3"
4343
indicatif = "0.17"
44+
rcgen = "0.14.7"
45+
tokio-rustls = "0.26.4"
46+
rustls-pemfile = "2.2.0"
47+
rustls = "0.23.37"
48+
time = "0.3.47"
49+
ratatui = "0.30.0"
50+
crossterm = "0.29.0"
51+
tokio-tungstenite = { version = "0.29.0", features = ["native-tls"] }
52+
hex = "0.4.3"
4453

4554
[dev-dependencies]
4655
axum = "0.7"

src/capture/har.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ pub fn import_har(
346346
status,
347347
duration_ms,
348348
error,
349+
ws_frames: vec![],
349350
};
350351
session.persist_capture(rec)?;
351352
}

src/capture/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod proxy;
44
pub mod redact;
55
pub mod session;
66
pub mod store;
7+
pub mod websocket;
78

89
pub use model::{CaptureRecord, HttpMessageSnapshot, ReplayRecord, SessionMeta};
910
pub use proxy::run_proxy;

src/capture/model.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use chrono::{DateTime, Utc};
22
use serde::{Deserialize, Serialize};
33

4+
use crate::capture::websocket::WsFrame;
5+
46
#[derive(Debug, Clone, Serialize, Deserialize)]
57
pub struct SessionMeta {
68
pub id: String,
@@ -30,6 +32,9 @@ pub struct CaptureRecord {
3032
pub status: Option<u16>,
3133
pub duration_ms: Option<u64>,
3234
pub error: Option<String>,
35+
/// WebSocket frames (populated only for upgraded WS connections).
36+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
37+
pub ws_frames: Vec<WsFrame>,
3338
}
3439

3540
#[derive(Debug, Clone, Serialize, Deserialize)]

src/capture/proxy.rs

Lines changed: 148 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
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
45
use std::convert::Infallible;
56
use std::io::{self, IsTerminal};
@@ -22,8 +23,10 @@ use tokio::net::{TcpListener, TcpStream};
2223
use crate::capture::model::{CaptureRecord, HttpMessageSnapshot};
2324
use crate::capture::redact;
2425
use crate::capture::session::SessionManager;
25-
use crate::error::{Error, Result};
2626
use 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

2831
const 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

4855
impl 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.
332426
async 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()

src/capture/store.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,31 @@ impl CaptureStore {
167167
}
168168
}
169169

170+
/// Resolve a session id or prefix. `last` returns the most recent.
171+
pub fn resolve_session_id(&self, id: &str) -> Result<String> {
172+
if id.eq_ignore_ascii_case("last") {
173+
let sessions = self.list_sessions()?;
174+
return sessions
175+
.first()
176+
.map(|s| s.id.clone())
177+
.ok_or_else(|| Error::SessionNotFound("last".into()));
178+
}
179+
let meta_path = self.session_meta_path(id);
180+
if meta_path.exists() {
181+
return Ok(id.to_string());
182+
}
183+
let sessions = self.list_sessions()?;
184+
let matches: Vec<_> = sessions.iter().filter(|s| s.id.starts_with(id)).collect();
185+
match matches.len() {
186+
0 => Err(Error::SessionNotFound(id.to_string())),
187+
1 => Ok(matches[0].id.clone()),
188+
_ => Err(Error::msg(format!(
189+
"ambiguous session id prefix {id:?}: {} matches",
190+
matches.len()
191+
))),
192+
}
193+
}
194+
170195
pub fn write_replay(&self, session_id: &str, rec: &ReplayRecord) -> Result<()> {
171196
let dir = self.session_dir(session_id).join("replays");
172197
crate::util::fs::ensure_dir(&dir)?;

0 commit comments

Comments
 (0)