1use std::fs;
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use rcgen::{CertificateParams, KeyPair};
18use serde::{Deserialize, Serialize};
19use serde_json::{json, Value};
20
21use crate::error::{CliError, ErrorKind};
22use crate::xdg;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CapturedExchange {
27 pub id: u64,
29 pub method: String,
31 pub url: String,
33 pub status: Option<u16>,
35 pub content_type: Option<String>,
37 pub request_headers: BTreeMapString,
39 pub response_headers: BTreeMapString,
41 pub request_body: Option<String>,
43 pub response_body: Option<String>,
45 pub host: Option<String>,
47 pub started_ms: u64,
49}
50
51pub type BTreeMapString = std::collections::BTreeMap<String, String>;
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CapturedWsFrame {
57 pub direction: String,
59 pub kind: String,
61 pub preview: String,
63 pub ts_ms: u64,
65}
66
67#[derive(Debug, Default)]
69pub struct MitmCapture {
70 pub items: Vec<CapturedExchange>,
72 pub ws_frames: Vec<CapturedWsFrame>,
74 next_id: u64,
76 path: Option<PathBuf>,
78 redact: bool,
80}
81
82impl MitmCapture {
83 pub fn new(path: Option<PathBuf>, redact: bool) -> Self {
85 Self {
86 items: Vec::new(),
87 ws_frames: Vec::new(),
88 next_id: 0,
89 path,
90 redact,
91 }
92 }
93
94 pub fn push_ws(&mut self, frame: CapturedWsFrame) {
96 if self.ws_frames.len() < 500 {
97 self.ws_frames.push(frame);
98 }
99 }
100
101 pub fn push(&mut self, mut ex: CapturedExchange) {
103 if self.redact {
104 redact_headers(&mut ex.request_headers);
105 redact_headers(&mut ex.response_headers);
106 }
107 ex.id = self.next_id;
108 self.next_id += 1;
109 self.items.push(ex);
110 }
111
112 pub fn save(&self) -> Result<PathBuf, CliError> {
114 let path = self
115 .path
116 .clone()
117 .ok_or_else(|| CliError::new(ErrorKind::Config, "mitm capture path not set"))?;
118 if let Some(parent) = path.parent() {
119 xdg::ensure_dir(parent)?;
120 }
121 let body = serde_json::to_vec_pretty(&json!({
122 "schema_version": 1,
123 "count": self.items.len(),
124 "ws_count": self.ws_frames.len(),
125 "items": self.items,
126 "ws_frames": self.ws_frames,
127 }))
128 .map_err(|e| CliError::new(ErrorKind::Data, format!("serialize mitm capture: {e}")))?;
129 atomic_write(&path, &body)?;
130 Ok(path)
131 }
132
133 pub fn load(path: &Path, redact: bool) -> Result<Self, CliError> {
135 if !path.exists() {
136 return Ok(Self::new(Some(path.to_path_buf()), redact));
137 }
138 let raw = fs::read_to_string(path)
139 .map_err(|e| CliError::new(ErrorKind::Io, format!("read mitm capture: {e}")))?;
140 let v: Value = serde_json::from_str(&raw)
141 .map_err(|e| CliError::new(ErrorKind::Data, format!("parse mitm capture: {e}")))?;
142 let items: Vec<CapturedExchange> =
143 serde_json::from_value(v.get("items").cloned().unwrap_or_else(|| json!([])))
144 .map_err(|e| CliError::new(ErrorKind::Data, format!("mitm items: {e}")))?;
145 let ws_frames: Vec<CapturedWsFrame> =
146 serde_json::from_value(v.get("ws_frames").cloned().unwrap_or_else(|| json!([])))
147 .unwrap_or_default();
148 let next_id = items.iter().map(|i| i.id).max().map(|m| m + 1).unwrap_or(0);
149 Ok(Self {
150 items,
151 ws_frames,
152 next_id,
153 path: Some(path.to_path_buf()),
154 redact,
155 })
156 }
157}
158
159fn redact_headers(h: &mut BTreeMapString) {
160 const SENSITIVE: &[&str] = &[
161 "authorization",
162 "cookie",
163 "set-cookie",
164 "proxy-authorization",
165 "x-api-key",
166 ];
167 for (k, v) in h.iter_mut() {
168 if SENSITIVE.iter().any(|s| k.eq_ignore_ascii_case(s)) {
169 *v = "[REDACTED]".into();
170 }
171 }
172}
173
174fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), CliError> {
175 let tmp = path.with_extension("tmp");
176 {
177 let mut f = fs::File::create(&tmp)
178 .map_err(|e| CliError::new(ErrorKind::Io, format!("mitm tmp: {e}")))?;
179 f.write_all(bytes)
180 .map_err(|e| CliError::new(ErrorKind::Io, format!("mitm write: {e}")))?;
181 f.sync_all()
182 .map_err(|e| CliError::new(ErrorKind::Io, format!("mitm fsync: {e}")))?;
183 }
184 fs::rename(&tmp, path)
185 .map_err(|e| CliError::new(ErrorKind::Io, format!("mitm rename: {e}")))?;
186 Ok(())
187}
188
189fn now_ms() -> u64 {
190 SystemTime::now()
191 .duration_since(UNIX_EPOCH)
192 .map(|d| d.as_millis() as u64)
193 .unwrap_or(0)
194}
195
196pub fn ensure_ca() -> Result<Value, CliError> {
198 let ca_dir = xdg::mitm_ca_dir()?;
199 xdg::ensure_dir(&ca_dir)?;
200 let cert_path = ca_dir.join("ca.pem");
201 let key_path = ca_dir.join("ca.key.pem");
202 if !cert_path.exists() || !key_path.exists() {
203 let mut params = CertificateParams::new(vec!["browser-automation-cli MITM CA".into()])
204 .map_err(|e| CliError::new(ErrorKind::Software, format!("rcgen params: {e}")))?;
205 params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
206 let key_pair = KeyPair::generate()
207 .map_err(|e| CliError::new(ErrorKind::Software, format!("rcgen key: {e}")))?;
208 let cert = params
209 .self_signed(&key_pair)
210 .map_err(|e| CliError::new(ErrorKind::Software, format!("rcgen self-signed: {e}")))?;
211 atomic_write(&cert_path, cert.pem().as_bytes())?;
212 atomic_write(&key_path, key_pair.serialize_pem().as_bytes())?;
213 #[cfg(unix)]
214 {
215 use std::os::unix::fs::PermissionsExt;
216 let _ = fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600));
217 let _ = fs::set_permissions(&cert_path, fs::Permissions::from_mode(0o600));
218 }
219 }
220 Ok(json!({
221 "ca_dir": ca_dir.display().to_string(),
222 "cert_path": cert_path.display().to_string(),
223 "key_path": key_path.display().to_string(),
224 "bind": "127.0.0.1",
225 "note": "CA ready for local one-shot MITM; never bind 0.0.0.0",
226 }))
227}
228
229pub fn default_capture_path() -> Result<PathBuf, CliError> {
231 Ok(xdg::mitm_capture_dir()?.join("capture.json"))
232}
233
234pub fn status() -> Result<Value, CliError> {
236 let ca = ensure_ca()?;
237 let path = default_capture_path()?;
238 let cap = MitmCapture::load(&path, true)?;
239 Ok(json!({
240 "ok": true,
241 "ca": ca,
242 "capture_path": path.display().to_string(),
243 "count": cap.items.len(),
244 "ws_count": cap.ws_frames.len(),
245 "websocket": true,
246 "bind_policy": "127.0.0.1 only",
247 "proxy_running": false,
248 "note": "one-shot: use `mitm start --seconds N` (hudsucker on 127.0.0.1 only; WS frames recorded)",
249 }))
250}
251
252pub fn list(host_filter: Option<&str>, limit: usize) -> Result<Value, CliError> {
254 let path = default_capture_path()?;
255 let cap = MitmCapture::load(&path, true)?;
256 let limit = limit.clamp(1, 10_000);
257 let items: Vec<Value> = cap
258 .items
259 .iter()
260 .filter(|e| {
261 host_filter
262 .map(|h| e.host.as_deref() == Some(h) || e.url.contains(h))
263 .unwrap_or(true)
264 })
265 .take(limit)
266 .map(|e| {
267 json!({
268 "id": e.id,
269 "method": e.method,
270 "url": e.url,
271 "status": e.status,
272 "host": e.host,
273 "content_type": e.content_type,
274 })
275 })
276 .collect();
277 Ok(json!({
278 "count": items.len(),
279 "items": items,
280 "capture_path": path.display().to_string(),
281 }))
282}
283
284pub fn get(id: u64) -> Result<Value, CliError> {
286 let path = default_capture_path()?;
287 let cap = MitmCapture::load(&path, true)?;
288 let item = cap
289 .items
290 .iter()
291 .find(|e| e.id == id)
292 .ok_or_else(|| CliError::new(ErrorKind::NoInput, format!("mitm id not found: {id}")))?;
293 serde_json::to_value(item).map_err(|e| CliError::new(ErrorKind::Data, format!("mitm get: {e}")))
294}
295
296pub fn export_har(out: &Path) -> Result<Value, CliError> {
298 let path = default_capture_path()?;
299 let cap = MitmCapture::load(&path, true)?;
300 let entries: Vec<Value> = cap
301 .items
302 .iter()
303 .map(|e| {
304 let req_headers: Vec<Value> = e
305 .request_headers
306 .iter()
307 .map(|(n, v)| json!({"name": n, "value": v}))
308 .collect();
309 let res_headers: Vec<Value> = e
310 .response_headers
311 .iter()
312 .map(|(n, v)| json!({"name": n, "value": v}))
313 .collect();
314 json!({
315 "startedDateTime": chrono_like(e.started_ms),
316 "time": 0,
317 "request": {
318 "method": e.method,
319 "url": e.url,
320 "httpVersion": "HTTP/1.1",
321 "headers": req_headers,
322 "queryString": [],
323 "cookies": [],
324 "headersSize": -1,
325 "bodySize": e.request_body.as_ref().map(|b| b.len() as i64).unwrap_or(0),
326 "postData": e.request_body.as_ref().map(|b| json!({
327 "mimeType": "application/octet-stream",
328 "text": b,
329 })),
330 },
331 "response": {
332 "status": e.status.unwrap_or(0),
333 "statusText": "",
334 "httpVersion": "HTTP/1.1",
335 "headers": res_headers,
336 "cookies": [],
337 "content": {
338 "size": e.response_body.as_ref().map(|b| b.len()).unwrap_or(0),
339 "mimeType": e.content_type.clone().unwrap_or_else(|| "application/octet-stream".into()),
340 "text": e.response_body.clone().unwrap_or_default(),
341 },
342 "redirectURL": "",
343 "headersSize": -1,
344 "bodySize": e.response_body.as_ref().map(|b| b.len() as i64).unwrap_or(-1),
345 },
346 "cache": {},
347 "timings": { "send": 0, "wait": 0, "receive": 0 },
348 })
349 })
350 .collect();
351
352 let har = json!({
353 "log": {
354 "version": "1.2",
355 "creator": {
356 "name": "browser-automation-cli",
357 "version": env!("CARGO_PKG_VERSION"),
358 },
359 "entries": entries,
360 }
361 });
362 let bytes = serde_json::to_vec_pretty(&har)
363 .map_err(|e| CliError::new(ErrorKind::Data, format!("har json: {e}")))?;
364 if let Some(parent) = out.parent() {
365 if !parent.as_os_str().is_empty() {
366 xdg::ensure_dir(parent)?;
367 }
368 }
369 atomic_write(out, &bytes)?;
370 Ok(json!({
371 "path": out.display().to_string(),
372 "entries": entries.len(),
373 "format": "HAR 1.2",
374 }))
375}
376
377fn chrono_like(ms: u64) -> String {
378 let secs = (ms / 1000) as i64;
380 time::OffsetDateTime::from_unix_timestamp(secs)
381 .map(|t| {
382 t.format(&time::format_description::well_known::Rfc3339)
383 .unwrap_or_else(|_| format!("{ms}"))
384 })
385 .unwrap_or_else(|_| format!("{ms}"))
386}
387
388pub fn domains() -> Result<Value, CliError> {
390 let path = default_capture_path()?;
391 let cap = MitmCapture::load(&path, true)?;
392 let mut hosts = std::collections::BTreeSet::new();
393 for e in &cap.items {
394 if let Some(h) = &e.host {
395 hosts.insert(h.clone());
396 }
397 }
398 let list: Vec<String> = hosts.into_iter().collect();
399 let count = list.len();
400 Ok(json!({ "hosts": list, "count": count }))
401}
402
403pub fn apis(kind: Option<&str>) -> Result<Value, CliError> {
405 let path = default_capture_path()?;
406 let cap = MitmCapture::load(&path, true)?;
407 let mut out = Vec::new();
408 for e in &cap.items {
409 let url_l = e.url.to_ascii_lowercase();
410 let is_gql = url_l.contains("graphql")
411 || e.request_body
412 .as_deref()
413 .map(|b| b.contains("\"query\"") || b.contains("query "))
414 .unwrap_or(false);
415 let is_rest = url_l.contains("/api")
416 || url_l.contains("/v1")
417 || url_l.contains("/v2")
418 || e.content_type
419 .as_deref()
420 .map(|c| c.contains("json"))
421 .unwrap_or(false);
422 let k = if is_gql {
423 "graphql"
424 } else if is_rest {
425 "rest"
426 } else {
427 "other"
428 };
429 if let Some(filter) = kind {
430 if filter != k {
431 continue;
432 }
433 }
434 out.push(json!({
435 "id": e.id,
436 "kind": k,
437 "method": e.method,
438 "url": e.url,
439 "status": e.status,
440 }));
441 }
442 Ok(json!({ "count": out.len(), "apis": out }))
443}
444
445pub fn import_cdp_network(events: &[Value]) -> Result<Value, CliError> {
447 let path = default_capture_path()?;
448 let mut cap = MitmCapture::load(&path, true)?;
449 let mut n = 0u64;
450 for ev in events {
451 let method = ev
452 .get("method")
453 .or_else(|| ev.get("request_method"))
454 .and_then(|v| v.as_str())
455 .unwrap_or("GET")
456 .to_string();
457 let url = ev
458 .get("url")
459 .and_then(|v| v.as_str())
460 .unwrap_or("")
461 .to_string();
462 if url.is_empty() {
463 continue;
464 }
465 let host = url::Url::parse(&url)
466 .ok()
467 .and_then(|u| u.host_str().map(|s| s.to_string()));
468 let status = ev
469 .get("status")
470 .or_else(|| ev.get("status_code"))
471 .and_then(|v| v.as_u64())
472 .map(|n| n as u16);
473 let mut req_h = BTreeMapString::new();
474 if let Some(obj) = ev.get("request_headers").and_then(|h| h.as_object()) {
475 for (k, v) in obj {
476 if let Some(s) = v.as_str() {
477 req_h.insert(k.clone(), s.to_string());
478 }
479 }
480 }
481 let mut res_h = BTreeMapString::new();
482 if let Some(obj) = ev.get("response_headers").and_then(|h| h.as_object()) {
483 for (k, v) in obj {
484 if let Some(s) = v.as_str() {
485 res_h.insert(k.clone(), s.to_string());
486 }
487 }
488 }
489 cap.push(CapturedExchange {
490 id: 0,
491 method,
492 url,
493 status,
494 content_type: ev
495 .get("mimeType")
496 .or_else(|| ev.get("content_type"))
497 .and_then(|v| v.as_str())
498 .map(|s| s.to_string()),
499 request_headers: req_h,
500 response_headers: res_h,
501 request_body: None,
502 response_body: None,
503 host,
504 started_ms: now_ms(),
505 });
506 n += 1;
507 }
508 let saved = cap.save()?;
509 Ok(json!({ "imported": n, "path": saved.display().to_string(), "total": cap.items.len() }))
510}
511
512pub type SharedCapture = Arc<Mutex<MitmCapture>>;
514
515pub fn shared_capture() -> Result<SharedCapture, CliError> {
517 let path = default_capture_path()?;
518 Ok(Arc::new(Mutex::new(MitmCapture::new(Some(path), true))))
519}
520
521pub async fn start_proxy_oneshot(seconds: u64) -> Result<Value, CliError> {
526 use hudsucker::rcgen::{Issuer, KeyPair};
527 use hudsucker::{
528 certificate_authority::RcgenAuthority,
529 hyper::{Request, Response},
530 rustls::crypto::aws_lc_rs,
531 tokio_tungstenite::tungstenite::Message,
532 Body, HttpContext, HttpHandler, Proxy, RequestOrResponse, WebSocketContext,
533 WebSocketHandler,
534 };
535 use std::net::SocketAddr;
536 use std::sync::atomic::{AtomicU16, Ordering};
537
538 let ca_meta = ensure_ca()?;
539 let cert_path = ca_meta
540 .get("cert_path")
541 .and_then(|v| v.as_str())
542 .ok_or_else(|| CliError::new(ErrorKind::Config, "CA cert path missing"))?;
543 let key_path = ca_meta
544 .get("key_path")
545 .and_then(|v| v.as_str())
546 .ok_or_else(|| CliError::new(ErrorKind::Config, "CA key path missing"))?;
547 let ca_cert = fs::read_to_string(cert_path)
548 .map_err(|e| CliError::new(ErrorKind::Io, format!("read CA cert: {e}")))?;
549 let ca_key = fs::read_to_string(key_path)
550 .map_err(|e| CliError::new(ErrorKind::Io, format!("read CA key: {e}")))?;
551 let key_pair = KeyPair::from_pem(&ca_key)
552 .map_err(|e| CliError::new(ErrorKind::Software, format!("parse CA key: {e}")))?;
553 let issuer = Issuer::from_ca_cert_pem(&ca_cert, key_pair)
554 .map_err(|e| CliError::new(ErrorKind::Software, format!("parse CA cert: {e}")))?;
555 let ca = RcgenAuthority::new(issuer, 1_000, aws_lc_rs::default_provider());
556
557 let capture = shared_capture()?;
558 let capture_h = capture.clone();
559
560 #[derive(Clone)]
561 struct CaptureHandler {
562 cap: SharedCapture,
563 }
564
565 impl HttpHandler for CaptureHandler {
566 async fn handle_request(
567 &mut self,
568 _ctx: &HttpContext,
569 req: Request<Body>,
570 ) -> RequestOrResponse {
571 let method = req.method().to_string();
572 let url = req.uri().to_string();
573 let host = req.uri().host().map(|s| s.to_string());
574 let mut headers = BTreeMapString::new();
575 for (k, v) in req.headers() {
576 if let Ok(val) = v.to_str() {
577 headers.insert(k.to_string(), val.to_string());
578 }
579 }
580 if let Ok(mut g) = self.cap.lock() {
581 g.push(CapturedExchange {
582 id: 0,
583 method,
584 url,
585 status: None,
586 content_type: None,
587 request_headers: headers,
588 response_headers: BTreeMapString::new(),
589 request_body: None,
590 response_body: None,
591 host,
592 started_ms: now_ms(),
593 });
594 }
595 req.into()
596 }
597
598 async fn handle_response(
599 &mut self,
600 _ctx: &HttpContext,
601 res: Response<Body>,
602 ) -> Response<Body> {
603 let status = res.status().as_u16();
604 if let Ok(mut g) = self.cap.lock() {
605 if let Some(last) = g.items.last_mut() {
606 last.status = Some(status);
607 }
608 }
609 res
610 }
611 }
612
613 impl WebSocketHandler for CaptureHandler {
614 async fn handle_message(
615 &mut self,
616 _ctx: &WebSocketContext,
617 msg: Message,
618 ) -> Option<Message> {
619 let (kind, preview) = match &msg {
620 Message::Text(t) => {
621 let s = t.to_string();
622 let prev: String = s.chars().take(256).collect();
623 ("text".into(), prev)
624 }
625 Message::Binary(b) => ("binary".into(), format!("<{} bytes>", b.len())),
626 Message::Ping(_) => ("ping".into(), String::new()),
627 Message::Pong(_) => ("pong".into(), String::new()),
628 Message::Close(_) => ("close".into(), String::new()),
629 _ => ("other".into(), String::new()),
630 };
631 let ts_ms = SystemTime::now()
632 .duration_since(UNIX_EPOCH)
633 .map(|d| d.as_millis() as u64)
634 .unwrap_or(0);
635 if let Ok(mut g) = self.cap.lock() {
636 g.push_ws(CapturedWsFrame {
637 direction: "unknown".into(),
638 kind,
639 preview,
640 ts_ms,
641 });
642 }
643 Some(msg)
644 }
645 }
646
647 let handler = CaptureHandler { cap: capture_h };
648 let bound_port = Arc::new(AtomicU16::new(0));
649 let bound_port_w = bound_port.clone();
650
651 let listener = std::net::TcpListener::bind("127.0.0.1:0")
653 .map_err(|e| CliError::new(ErrorKind::Io, format!("bind 127.0.0.1:0: {e}")))?;
654 let port = listener
655 .local_addr()
656 .map_err(|e| CliError::new(ErrorKind::Io, format!("local_addr: {e}")))?
657 .port();
658 drop(listener);
659 bound_port_w.store(port, Ordering::SeqCst);
660
661 let seconds = seconds.clamp(1, 600);
662 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
663 let proxy = Proxy::builder()
664 .with_addr(SocketAddr::from(([127, 0, 0, 1], port)))
665 .with_ca(ca)
666 .with_rustls_connector(aws_lc_rs::default_provider())
667 .with_http_handler(handler.clone())
668 .with_websocket_handler(handler)
669 .with_graceful_shutdown(async move {
670 let _ = rx.await;
671 })
672 .build()
673 .map_err(|e| CliError::new(ErrorKind::Software, format!("proxy build: {e}")))?;
674
675 let proxy_task = tokio::spawn(async move {
676 if let Err(e) = proxy.start().await {
677 tracing::error!(error = %e, "mitm proxy exited with error");
678 }
679 });
680
681 tokio::time::sleep(std::time::Duration::from_secs(seconds)).await;
682 let _ = tx.send(());
683 let _ = proxy_task.await;
684
685 let saved = if let Ok(g) = capture.lock() {
686 g.save().ok()
687 } else {
688 None
689 };
690 let count = capture.lock().map(|g| g.items.len()).unwrap_or(0);
691
692 Ok(json!({
693 "ok": true,
694 "bind": format!("127.0.0.1:{port}"),
695 "seconds": seconds,
696 "proxy_running": false,
697 "capture_count": count,
698 "capture_path": saved.map(|p| p.display().to_string()),
699 "note": "one-shot MITM finished; configure Chrome --proxy-server=http://127.0.0.1:PORT during the window",
700 }))
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706
707 #[test]
708 fn redact_auth() {
709 let mut h = BTreeMapString::new();
710 h.insert("Authorization".into(), "Bearer secret".into());
711 redact_headers(&mut h);
712 assert_eq!(
713 h.get("Authorization").map(|s| s.as_str()),
714 Some("[REDACTED]")
715 );
716 }
717}