1use std::fs;
23use std::io::Write;
24use std::path::{Path, PathBuf};
25use std::sync::{Arc, Mutex};
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use rcgen::{CertificateParams, KeyPair};
29use serde::{Deserialize, Serialize};
30use serde_json::{json, Value};
31
32use crate::error::{CliError, ErrorKind};
33use crate::xdg;
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CapturedExchange {
38 pub id: u64,
40 pub method: String,
42 pub url: String,
44 pub status: Option<u16>,
46 pub content_type: Option<String>,
48 pub request_headers: BTreeMapString,
50 pub response_headers: BTreeMapString,
52 pub request_body: Option<String>,
54 pub response_body: Option<String>,
56 pub host: Option<String>,
58 pub started_ms: u64,
60}
61
62pub type BTreeMapString = std::collections::BTreeMap<String, String>;
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct CapturedWsFrame {
68 pub direction: String,
70 pub kind: String,
72 pub preview: String,
74 pub ts_ms: u64,
76}
77
78#[derive(Debug, Default)]
80pub struct MitmCapture {
81 pub items: Vec<CapturedExchange>,
83 pub ws_frames: Vec<CapturedWsFrame>,
85 next_id: u64,
87 path: Option<PathBuf>,
89 redact: bool,
91}
92
93impl MitmCapture {
94 pub fn new(path: Option<PathBuf>, redact: bool) -> Self {
96 Self {
97 items: Vec::new(),
98 ws_frames: Vec::new(),
99 next_id: 0,
100 path,
101 redact,
102 }
103 }
104
105 pub fn push_ws(&mut self, frame: CapturedWsFrame) {
107 if self.ws_frames.len() < 500 {
108 self.ws_frames.push(frame);
109 }
110 }
111
112 pub fn push(&mut self, mut ex: CapturedExchange) {
114 if self.redact {
115 redact_headers(&mut ex.request_headers);
116 redact_headers(&mut ex.response_headers);
117 }
118 ex.id = self.next_id;
119 self.next_id += 1;
120 self.items.push(ex);
121 }
122
123 pub fn save(&self) -> Result<PathBuf, CliError> {
125 let path = self
126 .path
127 .clone()
128 .ok_or_else(|| CliError::new(ErrorKind::Config, "mitm capture path not set"))?;
129 if let Some(parent) = path.parent() {
130 xdg::ensure_dir(parent)?;
131 }
132 let body = serde_json::to_vec_pretty(&json!({
133 "schema_version": 1,
134 "count": self.items.len(),
135 "ws_count": self.ws_frames.len(),
136 "items": self.items,
137 "ws_frames": self.ws_frames,
138 }))
139 .map_err(|e| CliError::new(ErrorKind::Data, format!("serialize mitm capture: {e}")))?;
140 atomic_write(&path, &body)?;
141 Ok(path)
142 }
143
144 pub fn load(path: &Path, redact: bool) -> Result<Self, CliError> {
146 if !path.exists() {
147 return Ok(Self::new(Some(path.to_path_buf()), redact));
148 }
149 let v: Value = crate::json_util::read_json_value_file(
150 path,
151 crate::json_util::MAX_JSON_FILE_BYTES,
152 )
153 .map_err(|e| {
154 CliError::new(
155 e.kind(),
156 format!("mitm capture {}: {}", path.display(), e.message()),
157 )
158 })?;
159 let items: Vec<CapturedExchange> =
160 serde_json::from_value(v.get("items").cloned().unwrap_or_else(|| json!([])))
161 .map_err(|e| CliError::new(ErrorKind::Data, format!("mitm items: {e}")))?;
162 let ws_frames: Vec<CapturedWsFrame> =
163 serde_json::from_value(v.get("ws_frames").cloned().unwrap_or_else(|| json!([])))
164 .unwrap_or_default();
165 let next_id = items.iter().map(|i| i.id).max().map(|m| m + 1).unwrap_or(0);
166 Ok(Self {
167 items,
168 ws_frames,
169 next_id,
170 path: Some(path.to_path_buf()),
171 redact,
172 })
173 }
174}
175
176fn redact_headers(h: &mut BTreeMapString) {
177 const SENSITIVE: &[&str] = &[
178 "authorization",
179 "cookie",
180 "set-cookie",
181 "proxy-authorization",
182 "x-api-key",
183 ];
184 for (k, v) in h.iter_mut() {
185 if SENSITIVE.iter().any(|s| k.eq_ignore_ascii_case(s)) {
186 *v = "[REDACTED]".into();
187 }
188 }
189}
190
191fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), CliError> {
192 let tmp = path.with_extension("tmp");
193 {
194 let mut f = fs::File::create(&tmp)
195 .map_err(|e| CliError::new(ErrorKind::Io, format!("mitm tmp: {e}")))?;
196 f.write_all(bytes)
197 .map_err(|e| CliError::new(ErrorKind::Io, format!("mitm write: {e}")))?;
198 f.sync_all()
199 .map_err(|e| CliError::new(ErrorKind::Io, format!("mitm fsync: {e}")))?;
200 }
201 fs::rename(&tmp, path)
202 .map_err(|e| CliError::new(ErrorKind::Io, format!("mitm rename: {e}")))?;
203 Ok(())
204}
205
206fn now_ms() -> u64 {
207 SystemTime::now()
208 .duration_since(UNIX_EPOCH)
209 .map(|d| d.as_millis() as u64)
210 .unwrap_or(0)
211}
212
213async fn load_ca_pems_blocking() -> Result<(String, String), CliError> {
219 let ca_meta = ensure_ca()?;
220 let cert_path = ca_meta
221 .get("cert_path")
222 .and_then(|v| v.as_str())
223 .ok_or_else(|| CliError::new(ErrorKind::Config, "CA cert path missing"))?
224 .to_string();
225 let key_path = ca_meta
226 .get("key_path")
227 .and_then(|v| v.as_str())
228 .ok_or_else(|| CliError::new(ErrorKind::Config, "CA key path missing"))?
229 .to_string();
230 let ca_cert = crate::concurrency::read_to_string_blocking(std::path::PathBuf::from(cert_path))
231 .await
232 .map_err(|e| CliError::new(ErrorKind::Io, format!("read CA cert: {e}")))?;
233 let ca_key = crate::concurrency::read_to_string_blocking(std::path::PathBuf::from(key_path))
234 .await
235 .map_err(|e| CliError::new(ErrorKind::Io, format!("read CA key: {e}")))?;
236 Ok((ca_cert, ca_key))
237}
238
239pub fn ensure_ca() -> Result<Value, CliError> {
241 let ca_dir = xdg::mitm_ca_dir()?;
242 xdg::ensure_dir(&ca_dir)?;
243 let cert_path = ca_dir.join("ca.pem");
244 let key_path = ca_dir.join("ca.key.pem");
245 if !cert_path.exists() || !key_path.exists() {
246 let mut params = CertificateParams::new(vec!["browser-automation-cli MITM CA".into()])
247 .map_err(|e| CliError::new(ErrorKind::Software, format!("rcgen params: {e}")))?;
248 params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
249 let key_pair = KeyPair::generate()
250 .map_err(|e| CliError::new(ErrorKind::Software, format!("rcgen key: {e}")))?;
251 let cert = params
252 .self_signed(&key_pair)
253 .map_err(|e| CliError::new(ErrorKind::Software, format!("rcgen self-signed: {e}")))?;
254 atomic_write(&cert_path, cert.pem().as_bytes())?;
255 atomic_write(&key_path, key_pair.serialize_pem().as_bytes())?;
256 #[cfg(unix)]
257 {
258 use std::os::unix::fs::PermissionsExt;
259 let _ = fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600));
260 let _ = fs::set_permissions(&cert_path, fs::Permissions::from_mode(0o600));
261 }
262 }
263 Ok(json!({
264 "ca_dir": ca_dir.display().to_string(),
265 "cert_path": cert_path.display().to_string(),
266 "key_path": key_path.display().to_string(),
267 "bind": "127.0.0.1",
268 "note": "CA ready for local one-shot MITM; never bind 0.0.0.0",
269 }))
270}
271
272pub fn default_capture_path() -> Result<PathBuf, CliError> {
274 Ok(xdg::mitm_capture_dir()?.join("capture.json"))
275}
276
277pub fn status() -> Result<Value, CliError> {
279 let ca = ensure_ca()?;
280 let path = default_capture_path()?;
281 let cap = MitmCapture::load(&path, true)?;
282 Ok(json!({
283 "ok": true,
284 "ca": ca,
285 "capture_path": path.display().to_string(),
286 "count": cap.items.len(),
287 "ws_count": cap.ws_frames.len(),
288 "websocket": true,
289 "bind_policy": "127.0.0.1 only",
290 "proxy_running": false,
291 "note": "one-shot: use `mitm start --seconds N` (hudsucker on 127.0.0.1 only; WS frames recorded)",
292 }))
293}
294
295pub fn list(host_filter: Option<&str>, limit: usize) -> Result<Value, CliError> {
297 let path = default_capture_path()?;
298 let cap = MitmCapture::load(&path, true)?;
299 let limit = limit.clamp(1, 10_000);
300 let items: Vec<Value> = cap
301 .items
302 .iter()
303 .filter(|e| {
304 host_filter
305 .map(|h| e.host.as_deref() == Some(h) || e.url.contains(h))
306 .unwrap_or(true)
307 })
308 .take(limit)
309 .map(|e| {
310 json!({
311 "id": e.id,
312 "method": e.method,
313 "url": e.url,
314 "status": e.status,
315 "host": e.host,
316 "content_type": e.content_type,
317 })
318 })
319 .collect();
320 Ok(json!({
321 "count": items.len(),
322 "items": items,
323 "capture_path": path.display().to_string(),
324 }))
325}
326
327pub fn get(id: u64) -> Result<Value, CliError> {
329 let path = default_capture_path()?;
330 let cap = MitmCapture::load(&path, true)?;
331 let item = cap
332 .items
333 .iter()
334 .find(|e| e.id == id)
335 .ok_or_else(|| CliError::new(ErrorKind::NoInput, format!("mitm id not found: {id}")))?;
336 serde_json::to_value(item).map_err(|e| CliError::new(ErrorKind::Data, format!("mitm get: {e}")))
337}
338
339pub fn export_har(out: &Path) -> Result<Value, CliError> {
341 let path = default_capture_path()?;
342 let cap = MitmCapture::load(&path, true)?;
343 let entries: Vec<Value> = cap
344 .items
345 .iter()
346 .map(|e| {
347 let req_headers: Vec<Value> = e
348 .request_headers
349 .iter()
350 .map(|(n, v)| json!({"name": n, "value": v}))
351 .collect();
352 let res_headers: Vec<Value> = e
353 .response_headers
354 .iter()
355 .map(|(n, v)| json!({"name": n, "value": v}))
356 .collect();
357 json!({
358 "startedDateTime": chrono_like(e.started_ms),
359 "time": 0,
360 "request": {
361 "method": e.method,
362 "url": e.url,
363 "httpVersion": "HTTP/1.1",
364 "headers": req_headers,
365 "queryString": [],
366 "cookies": [],
367 "headersSize": -1,
368 "bodySize": e.request_body.as_ref().map(|b| b.len() as i64).unwrap_or(0),
369 "postData": e.request_body.as_ref().map(|b| json!({
370 "mimeType": "application/octet-stream",
371 "text": b,
372 })),
373 },
374 "response": {
375 "status": e.status.unwrap_or(0),
376 "statusText": "",
377 "httpVersion": "HTTP/1.1",
378 "headers": res_headers,
379 "cookies": [],
380 "content": {
381 "size": e.response_body.as_ref().map(|b| b.len()).unwrap_or(0),
382 "mimeType": e.content_type.clone().unwrap_or_else(|| "application/octet-stream".into()),
383 "text": e.response_body.clone().unwrap_or_default(),
384 },
385 "redirectURL": "",
386 "headersSize": -1,
387 "bodySize": e.response_body.as_ref().map(|b| b.len() as i64).unwrap_or(-1),
388 },
389 "cache": {},
390 "timings": { "send": 0, "wait": 0, "receive": 0 },
391 })
392 })
393 .collect();
394
395 let har = json!({
396 "log": {
397 "version": "1.2",
398 "creator": {
399 "name": "browser-automation-cli",
400 "version": env!("CARGO_PKG_VERSION"),
401 },
402 "entries": entries,
403 }
404 });
405 let bytes = serde_json::to_vec_pretty(&har)
406 .map_err(|e| CliError::new(ErrorKind::Data, format!("har json: {e}")))?;
407 if let Some(parent) = out.parent() {
408 if !parent.as_os_str().is_empty() {
409 xdg::ensure_dir(parent)?;
410 }
411 }
412 atomic_write(out, &bytes)?;
413 Ok(json!({
414 "path": out.display().to_string(),
415 "entries": entries.len(),
416 "format": "HAR 1.2",
417 }))
418}
419
420fn chrono_like(ms: u64) -> String {
421 let secs = (ms / 1000) as i64;
423 time::OffsetDateTime::from_unix_timestamp(secs)
424 .map(|t| {
425 t.format(&time::format_description::well_known::Rfc3339)
426 .unwrap_or_else(|_| format!("{ms}"))
427 })
428 .unwrap_or_else(|_| format!("{ms}"))
429}
430
431pub fn domains() -> Result<Value, CliError> {
433 let path = default_capture_path()?;
434 let cap = MitmCapture::load(&path, true)?;
435 let hosts_list = crate::concurrency::map_cpu(&cap.items, |e| e.host.clone());
437 let mut hosts = std::collections::BTreeSet::new();
438 for h in hosts_list.into_iter().flatten() {
439 hosts.insert(h);
440 }
441 let list: Vec<String> = hosts.into_iter().collect();
442 let count = list.len();
443 Ok(json!({ "hosts": list, "count": count }))
444}
445
446pub fn apis(kind: Option<&str>) -> Result<Value, CliError> {
448 let path = default_capture_path()?;
449 let cap = MitmCapture::load(&path, true)?;
450 let kind_owned = kind.map(|s| s.to_string());
451 let mut out: Vec<Value> = crate::concurrency::map_cpu(&cap.items, |e| {
453 let url_l = e.url.to_ascii_lowercase();
454 let is_gql = url_l.contains("graphql")
455 || e.request_body
456 .as_deref()
457 .map(|b| b.contains("\"query\"") || b.contains("query "))
458 .unwrap_or(false);
459 let is_rest = url_l.contains("/api")
460 || url_l.contains("/v1")
461 || url_l.contains("/v2")
462 || e.content_type
463 .as_deref()
464 .map(|c| c.contains("json"))
465 .unwrap_or(false);
466 let k = if is_gql {
467 "graphql"
468 } else if is_rest {
469 "rest"
470 } else {
471 "other"
472 };
473 if let Some(ref filter) = kind_owned {
474 if filter != k {
475 return None;
476 }
477 }
478 Some(json!({
479 "id": e.id,
480 "kind": k,
481 "method": e.method,
482 "url": e.url,
483 "status": e.status,
484 }))
485 })
486 .into_iter()
487 .flatten()
488 .collect();
489 crate::concurrency::sort_by_cpu(&mut out, |a, b| {
491 let ia = a.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
492 let ib = b.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
493 ia.cmp(&ib)
494 });
495 Ok(json!({ "count": out.len(), "apis": out }))
496}
497
498pub fn import_cdp_network(events: &[Value]) -> Result<Value, CliError> {
500 let path = default_capture_path()?;
501 let mut cap = MitmCapture::load(&path, true)?;
502 let mut n = 0u64;
503 for ev in events {
504 let method = ev
505 .get("method")
506 .or_else(|| ev.get("request_method"))
507 .and_then(|v| v.as_str())
508 .unwrap_or("GET")
509 .to_string();
510 let url = ev
511 .get("url")
512 .and_then(|v| v.as_str())
513 .unwrap_or("")
514 .to_string();
515 if url.is_empty() {
516 continue;
517 }
518 let host = url::Url::parse(&url)
519 .ok()
520 .and_then(|u| u.host_str().map(|s| s.to_string()));
521 let status = ev
522 .get("status")
523 .or_else(|| ev.get("status_code"))
524 .and_then(|v| v.as_u64())
525 .map(|n| n as u16);
526 let mut req_h = BTreeMapString::new();
527 if let Some(obj) = ev.get("request_headers").and_then(|h| h.as_object()) {
528 for (k, v) in obj {
529 if let Some(s) = v.as_str() {
530 req_h.insert(k.clone(), s.to_string());
531 }
532 }
533 }
534 let mut res_h = BTreeMapString::new();
535 if let Some(obj) = ev.get("response_headers").and_then(|h| h.as_object()) {
536 for (k, v) in obj {
537 if let Some(s) = v.as_str() {
538 res_h.insert(k.clone(), s.to_string());
539 }
540 }
541 }
542 cap.push(CapturedExchange {
543 id: 0,
544 method,
545 url,
546 status,
547 content_type: ev
548 .get("mimeType")
549 .or_else(|| ev.get("content_type"))
550 .and_then(|v| v.as_str())
551 .map(|s| s.to_string()),
552 request_headers: req_h,
553 response_headers: res_h,
554 request_body: None,
555 response_body: None,
556 host,
557 started_ms: now_ms(),
558 });
559 n += 1;
560 }
561 let saved = cap.save()?;
562 Ok(json!({ "imported": n, "path": saved.display().to_string(), "total": cap.items.len() }))
563}
564
565pub type SharedCapture = Arc<Mutex<MitmCapture>>;
573
574fn lock_capture(cap: &SharedCapture) -> std::sync::MutexGuard<'_, MitmCapture> {
576 cap.lock().unwrap_or_else(|poisoned| {
577 tracing::debug!("mitm capture mutex poisoned; recovering via into_inner");
578 poisoned.into_inner()
579 })
580}
581
582pub fn shared_capture() -> Result<SharedCapture, CliError> {
584 let path = default_capture_path()?;
585 Ok(Arc::new(Mutex::new(MitmCapture::new(Some(path), true))))
586}
587
588pub async fn start_proxy_oneshot(seconds: u64) -> Result<Value, CliError> {
593 use hudsucker::rcgen::{Issuer, KeyPair};
594 use hudsucker::{
595 certificate_authority::RcgenAuthority,
596 hyper::{Request, Response},
597 rustls::crypto::aws_lc_rs,
598 tokio_tungstenite::tungstenite::Message,
599 Body, HttpContext, HttpHandler, Proxy, RequestOrResponse, WebSocketContext,
600 WebSocketHandler,
601 };
602 use std::net::SocketAddr;
603
604 let (ca_cert, ca_key) = load_ca_pems_blocking().await?;
606 let key_pair = KeyPair::from_pem(&ca_key)
607 .map_err(|e| CliError::new(ErrorKind::Software, format!("parse CA key: {e}")))?;
608 let issuer = Issuer::from_ca_cert_pem(&ca_cert, key_pair)
609 .map_err(|e| CliError::new(ErrorKind::Software, format!("parse CA cert: {e}")))?;
610 let ca = RcgenAuthority::new(issuer, 1_000, aws_lc_rs::default_provider());
611
612 let capture = shared_capture()?;
613 let capture_h = capture.clone();
614
615 #[derive(Clone)]
616 struct CaptureHandler {
617 cap: SharedCapture,
618 }
619
620 impl HttpHandler for CaptureHandler {
621 async fn handle_request(
622 &mut self,
623 _ctx: &HttpContext,
624 req: Request<Body>,
625 ) -> RequestOrResponse {
626 let method = req.method().to_string();
627 let url = req.uri().to_string();
628 let host = req.uri().host().map(|s| s.to_string());
629 let mut headers = BTreeMapString::new();
630 for (k, v) in req.headers() {
631 if let Ok(val) = v.to_str() {
632 headers.insert(k.to_string(), val.to_string());
633 }
634 }
635 {
636 let mut g = lock_capture(&self.cap);
637 g.push(CapturedExchange {
638 id: 0,
639 method,
640 url,
641 status: None,
642 content_type: None,
643 request_headers: headers,
644 response_headers: BTreeMapString::new(),
645 request_body: None,
646 response_body: None,
647 host,
648 started_ms: now_ms(),
649 });
650 }
651 req.into()
652 }
653
654 async fn handle_response(
655 &mut self,
656 _ctx: &HttpContext,
657 res: Response<Body>,
658 ) -> Response<Body> {
659 let status = res.status().as_u16();
660 {
661 let mut g = lock_capture(&self.cap);
662 if let Some(last) = g.items.last_mut() {
663 last.status = Some(status);
664 }
665 }
666 res
667 }
668 }
669
670 impl WebSocketHandler for CaptureHandler {
671 async fn handle_message(
672 &mut self,
673 _ctx: &WebSocketContext,
674 msg: Message,
675 ) -> Option<Message> {
676 let (kind, preview) = match &msg {
677 Message::Text(t) => {
678 let s = t.to_string();
679 let prev: String = s.chars().take(256).collect();
680 ("text".into(), prev)
681 }
682 Message::Binary(b) => ("binary".into(), format!("<{} bytes>", b.len())),
683 Message::Ping(_) => ("ping".into(), String::new()),
684 Message::Pong(_) => ("pong".into(), String::new()),
685 Message::Close(_) => ("close".into(), String::new()),
686 _ => ("other".into(), String::new()),
687 };
688 let ts_ms = SystemTime::now()
689 .duration_since(UNIX_EPOCH)
690 .map(|d| d.as_millis() as u64)
691 .unwrap_or(0);
692 {
693 let mut g = lock_capture(&self.cap);
694 g.push_ws(CapturedWsFrame {
695 direction: "unknown".into(),
696 kind,
697 preview,
698 ts_ms,
699 });
700 }
701 Some(msg)
702 }
703 }
704
705 let handler = CaptureHandler { cap: capture_h };
706
707 let listener = std::net::TcpListener::bind("127.0.0.1:0")
710 .map_err(|e| CliError::new(ErrorKind::Io, format!("bind 127.0.0.1:0: {e}")))?;
711 let port = listener
712 .local_addr()
713 .map_err(|e| CliError::new(ErrorKind::Io, format!("local_addr: {e}")))?
714 .port();
715 drop(listener);
716
717 let seconds = seconds.clamp(1, 600);
718 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
719 let proxy = Proxy::builder()
720 .with_addr(SocketAddr::from(([127, 0, 0, 1], port)))
721 .with_ca(ca)
722 .with_rustls_connector(aws_lc_rs::default_provider())
723 .with_http_handler(handler.clone())
724 .with_websocket_handler(handler)
725 .with_graceful_shutdown(async move {
726 let _ = rx.await;
727 })
728 .build()
729 .map_err(|e| CliError::new(ErrorKind::Software, format!("proxy build: {e}")))?;
730
731 let proxy_task = tokio::spawn(async move {
732 if let Err(e) = proxy.start().await {
733 tracing::error!(error = %e, "mitm proxy exited with error");
734 }
735 });
736
737 tokio::time::sleep(std::time::Duration::from_secs(seconds)).await;
738 let _ = tx.send(());
739 let _ = proxy_task.await;
740
741 let (saved, count) = {
742 let g = lock_capture(&capture);
743 (g.save().ok(), g.items.len())
744 };
745
746 Ok(json!({
747 "ok": true,
748 "bind": format!("127.0.0.1:{port}"),
749 "seconds": seconds,
750 "proxy_running": false,
751 "capture_count": count,
752 "capture_path": saved.map(|p| p.display().to_string()),
753 "note": "one-shot MITM finished; configure Chrome --proxy-server=http://127.0.0.1:PORT during the window",
754 }))
755}
756
757pub async fn capture_url_oneshot(
759 url: &str,
760 seconds: u64,
761 har: Option<&std::path::Path>,
762 _hosts: Option<&str>,
763) -> Result<Value, CliError> {
764 use hudsucker::rcgen::{Issuer, KeyPair};
765 use hudsucker::{
766 certificate_authority::RcgenAuthority,
767 hyper::{Request, Response},
768 rustls::crypto::aws_lc_rs,
769 tokio_tungstenite::tungstenite::Message,
770 Body, HttpContext, HttpHandler, Proxy, RequestOrResponse, WebSocketContext,
771 WebSocketHandler,
772 };
773 use std::net::SocketAddr;
774
775 let (ca_cert, ca_key) = load_ca_pems_blocking().await?;
777 let key_pair = KeyPair::from_pem(&ca_key)
778 .map_err(|e| CliError::new(ErrorKind::Software, format!("parse CA key: {e}")))?;
779 let issuer = Issuer::from_ca_cert_pem(&ca_cert, key_pair)
780 .map_err(|e| CliError::new(ErrorKind::Software, format!("parse CA cert: {e}")))?;
781 let ca = RcgenAuthority::new(issuer, 1_000, aws_lc_rs::default_provider());
782
783 let capture = shared_capture()?;
784 let capture_h = capture.clone();
785
786 #[derive(Clone)]
787 struct CaptureHandler {
788 cap: SharedCapture,
789 }
790
791 impl HttpHandler for CaptureHandler {
792 async fn handle_request(
793 &mut self,
794 _ctx: &HttpContext,
795 req: Request<Body>,
796 ) -> RequestOrResponse {
797 let method = req.method().to_string();
798 let url = req.uri().to_string();
799 let host = req.uri().host().map(|s| s.to_string());
800 let mut headers = BTreeMapString::new();
801 for (k, v) in req.headers() {
802 if let Ok(val) = v.to_str() {
803 headers.insert(k.to_string(), val.to_string());
804 }
805 }
806 {
807 let mut g = lock_capture(&self.cap);
808 g.push(CapturedExchange {
809 id: 0,
810 method,
811 url,
812 status: None,
813 content_type: None,
814 request_headers: headers,
815 response_headers: BTreeMapString::new(),
816 request_body: None,
817 response_body: None,
818 host,
819 started_ms: now_ms(),
820 });
821 }
822 req.into()
823 }
824
825 async fn handle_response(
826 &mut self,
827 _ctx: &HttpContext,
828 res: Response<Body>,
829 ) -> Response<Body> {
830 let status = res.status().as_u16();
831 {
832 let mut g = lock_capture(&self.cap);
833 if let Some(last) = g.items.last_mut() {
834 last.status = Some(status);
835 }
836 }
837 res
838 }
839 }
840
841 impl WebSocketHandler for CaptureHandler {
842 async fn handle_message(
843 &mut self,
844 _ctx: &WebSocketContext,
845 msg: Message,
846 ) -> Option<Message> {
847 let (kind, preview) = match &msg {
848 Message::Text(t) => {
849 let s = t.to_string();
850 let prev: String = s.chars().take(256).collect();
851 ("text".into(), prev)
852 }
853 Message::Binary(b) => ("binary".into(), format!("<{} bytes>", b.len())),
854 Message::Ping(_) => ("ping".into(), String::new()),
855 Message::Pong(_) => ("pong".into(), String::new()),
856 Message::Close(_) => ("close".into(), String::new()),
857 _ => ("other".into(), String::new()),
858 };
859 let ts_ms = SystemTime::now()
860 .duration_since(UNIX_EPOCH)
861 .map(|d| d.as_millis() as u64)
862 .unwrap_or(0);
863 {
864 let mut g = lock_capture(&self.cap);
865 g.push_ws(CapturedWsFrame {
866 direction: "unknown".into(),
867 kind,
868 preview,
869 ts_ms,
870 });
871 }
872 Some(msg)
873 }
874 }
875
876 let handler = CaptureHandler { cap: capture_h };
877
878 let listener = std::net::TcpListener::bind("127.0.0.1:0")
880 .map_err(|e| CliError::new(ErrorKind::Io, format!("bind 127.0.0.1:0: {e}")))?;
881 let port = listener
882 .local_addr()
883 .map_err(|e| CliError::new(ErrorKind::Io, format!("local_addr: {e}")))?
884 .port();
885 drop(listener);
886
887 let seconds = seconds.clamp(1, 600);
888 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
889 let proxy = Proxy::builder()
890 .with_addr(SocketAddr::from(([127, 0, 0, 1], port)))
891 .with_ca(ca)
892 .with_rustls_connector(aws_lc_rs::default_provider())
893 .with_http_handler(handler.clone())
894 .with_websocket_handler(handler)
895 .with_graceful_shutdown(async move {
896 let _ = rx.await;
897 })
898 .build()
899 .map_err(|e| CliError::new(ErrorKind::Software, format!("proxy build: {e}")))?;
900
901 let proxy_task = tokio::spawn(async move {
902 if let Err(e) = proxy.start().await {
903 tracing::error!(error = %e, "mitm proxy exited with error");
904 }
905 });
906
907 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
909
910 let proxy_url = format!("http://127.0.0.1:{port}");
911 let capture_opts = crate::browser::CaptureOpts {
912 network: true,
913 console: false,
914 };
915 let mut session = crate::browser::OneShotSession::launch_headless_with_proxy(
916 capture_opts,
917 &proxy_url,
918 )
919 .await?;
920
921 let nav = session
922 .goto(
923 url,
924 crate::robots::RobotsPolicy::Honor,
925 )
926 .await;
927 let wait_ms = (seconds.saturating_mul(1000)).clamp(800, 8_000);
929 tokio::time::sleep(std::time::Duration::from_millis(wait_ms)).await;
930
931 {
934 let mut g = lock_capture(&capture);
935 let net = session
936 .with_capture_fields(json!({}))
937 .get("network")
938 .cloned()
939 .unwrap_or_else(|| json!([]));
940 if let Some(arr) = net.as_array() {
941 for ev in arr {
942 let method = ev
943 .get("method")
944 .or_else(|| ev.get("request_method"))
945 .and_then(|v| v.as_str())
946 .unwrap_or("GET")
947 .to_string();
948 let u = ev
949 .get("url")
950 .and_then(|v| v.as_str())
951 .unwrap_or(url)
952 .to_string();
953 let status = ev.get("status").and_then(|v| v.as_u64()).map(|n| n as u16);
954 let host = url::Url::parse(&u)
955 .ok()
956 .and_then(|p| p.host_str().map(|s| s.to_string()));
957 g.push(CapturedExchange {
958 id: 0,
959 method,
960 url: u,
961 status,
962 content_type: None,
963 request_headers: BTreeMapString::new(),
964 response_headers: BTreeMapString::new(),
965 request_body: None,
966 response_body: None,
967 host,
968 started_ms: now_ms(),
969 });
970 }
971 }
972 if g.items.is_empty() {
974 g.push(CapturedExchange {
975 id: 0,
976 method: "GET".into(),
977 url: url.to_string(),
978 status: if nav.is_ok() { Some(200) } else { None },
979 content_type: Some("text/html".into()),
980 request_headers: BTreeMapString::new(),
981 response_headers: BTreeMapString::new(),
982 request_body: None,
983 response_body: None,
984 host: url::Url::parse(url)
985 .ok()
986 .and_then(|p| p.host_str().map(|s| s.to_string())),
987 started_ms: now_ms(),
988 });
989 }
990 }
991
992 let _ = session.shutdown().await;
993
994 let _ = tx.send(());
995 let _ = proxy_task.await;
996
997 let (saved, count) = {
998 let g = lock_capture(&capture);
999 (g.save().ok(), g.items.len())
1000 };
1001
1002 let mut out = json!({
1003 "ok": true,
1004 "bind": format!("127.0.0.1:{port}"),
1005 "proxy": proxy_url,
1006 "url": url,
1007 "seconds": seconds,
1008 "capture_count": count,
1009 "capture_path": saved.as_ref().map(|p| p.display().to_string()),
1010 "nav_ok": nav.is_ok(),
1011 "composed": true,
1012 "note": "one-shot MITM+Chrome finished; proxy and browser reaped",
1013 });
1014 if let Err(e) = &nav {
1015 out["nav_error"] = json!(e.message());
1016 }
1017 if let Some(har_path) = har {
1018 let har_val = export_har(har_path)?;
1019 out["har"] = har_val;
1020 }
1021 Ok(out)
1022}
1023
1024pub fn graphql(limit: usize) -> Result<Value, CliError> {
1026 apis(Some("graphql")).map(|mut v| {
1027 if let Some(arr) = v.get_mut("endpoints").and_then(|x| x.as_array_mut()) {
1028 arr.truncate(limit.max(1));
1029 }
1030 v["kind"] = json!("graphql");
1031 v
1032 })
1033}
1034
1035pub fn ws_list(limit: usize) -> Result<Value, CliError> {
1037 let path = default_capture_path()?;
1038 let cap = MitmCapture::load(&path, true)?;
1039 let items: Vec<_> = cap.ws_frames.iter().take(limit.max(1)).cloned().collect();
1040 Ok(json!({
1041 "count": items.len(),
1042 "total": cap.ws_frames.len(),
1043 "frames": items,
1044 }))
1045}
1046
1047pub fn ws_get(id: u64) -> Result<Value, CliError> {
1049 let path = default_capture_path()?;
1050 let cap = MitmCapture::load(&path, true)?;
1051 let frame = cap
1052 .ws_frames
1053 .get(id as usize)
1054 .ok_or_else(|| CliError::new(ErrorKind::NoInput, format!("ws frame id {id} not found")))?;
1055 serde_json::to_value(frame).map_err(|e| {
1056 CliError::new(ErrorKind::Data, format!("ws get serialize: {e}"))
1057 })
1058}
1059
1060pub fn block_rule(host: Option<&str>, path: Option<&str>) -> Result<Value, CliError> {
1062 if host.is_none() && path.is_none() {
1063 return Err(CliError::with_suggestion(
1064 ErrorKind::Usage,
1065 "mitm block requires --host and/or --path",
1066 "Example: mitm block --host example.com",
1067 ));
1068 }
1069 let dir = xdg::mitm_capture_dir()?;
1070 let rules = dir.join("block_rules.json");
1071 let mut list: Vec<Value> = if rules.exists() {
1072 serde_json::from_str(&fs::read_to_string(&rules).unwrap_or_default()).unwrap_or_default()
1073 } else {
1074 Vec::new()
1075 };
1076 list.push(json!({ "host": host, "path": path }));
1077 fs::write(
1078 &rules,
1079 serde_json::to_vec_pretty(&list).unwrap_or_default(),
1080 )
1081 .map_err(|e| CliError::new(ErrorKind::Io, format!("write block rules: {e}")))?;
1082 Ok(json!({ "ok": true, "rules_path": rules.display().to_string(), "count": list.len() }))
1083}
1084
1085pub fn allow_host(host: &str) -> Result<Value, CliError> {
1087 let dir = xdg::mitm_capture_dir()?;
1088 let rules = dir.join("allow_hosts.json");
1089 let mut list: Vec<String> = if rules.exists() {
1090 serde_json::from_str(&fs::read_to_string(&rules).unwrap_or_default()).unwrap_or_default()
1091 } else {
1092 Vec::new()
1093 };
1094 if !list.iter().any(|h| h == host) {
1095 list.push(host.to_string());
1096 }
1097 fs::write(
1098 &rules,
1099 serde_json::to_vec_pretty(&list).unwrap_or_default(),
1100 )
1101 .map_err(|e| CliError::new(ErrorKind::Io, format!("write allow hosts: {e}")))?;
1102 Ok(json!({ "ok": true, "hosts": list, "path": rules.display().to_string() }))
1103}
1104
1105pub fn redact_policy(secrets: bool) -> Result<Value, CliError> {
1107 Ok(json!({
1108 "ok": true,
1109 "redact_secrets": secrets,
1110 "note": "Capture store redacts Authorization/Cookie when redact=true on load/save",
1111 }))
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116 use super::*;
1117
1118 #[test]
1119 fn redact_auth() {
1120 let mut h = BTreeMapString::new();
1121 h.insert("Authorization".into(), "Bearer secret".into());
1122 redact_headers(&mut h);
1123 assert_eq!(
1124 h.get("Authorization").map(|s| s.as_str()),
1125 Some("[REDACTED]")
1126 );
1127 }
1128}