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