Skip to main content

browser_automation_cli/
mitm_local.rs

1//! One-shot local MITM capture helpers (PRD §5E).
2//!
3//! This module:
4//! - Generates/loads a local CA under XDG data (`mitm/ca`)
5//! - Stores invocation captures under XDG state (`mitm/`)
6//! - Exports HAR JSON without Python mitmproxy
7//!
8//! Full TLS intercept proxy (hudsucker) can attach to the same capture store.
9//! CDP Network remains complementary and can feed the same HAR exporter.
10
11use 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/// One captured HTTP(S) exchange (agent-facing, secrets redacted by default).
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CapturedExchange {
27    /// Monotonic id within the capture.
28    pub id: u64,
29    /// Request method.
30    pub method: String,
31    /// Absolute URL.
32    pub url: String,
33    /// HTTP status if known.
34    pub status: Option<u16>,
35    /// Resource / content type hint.
36    pub content_type: Option<String>,
37    /// Request headers (redacted).
38    pub request_headers: BTreeMapString,
39    /// Response headers (redacted).
40    pub response_headers: BTreeMapString,
41    /// Truncated request body.
42    pub request_body: Option<String>,
43    /// Truncated response body.
44    pub response_body: Option<String>,
45    /// Host extracted from URL.
46    pub host: Option<String>,
47    /// Wall-clock unix millis.
48    pub started_ms: u64,
49}
50
51/// Stable map type alias for headers.
52pub type BTreeMapString = std::collections::BTreeMap<String, String>;
53
54/// In-memory + disk-backed capture for one process.
55#[derive(Debug, Default)]
56pub struct MitmCapture {
57    /// Captured exchanges.
58    pub items: Vec<CapturedExchange>,
59    /// Next id.
60    next_id: u64,
61    /// Optional path for persistence.
62    path: Option<PathBuf>,
63    /// Redact Authorization/Cookie by default.
64    redact: bool,
65}
66
67impl MitmCapture {
68    /// Create a new capture optionally bound to a path.
69    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    /// Append an exchange.
79    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    /// Persist JSON snapshot.
90    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    /// Load from disk.
108    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
169/// Ensure CA key/cert exist under XDG; return paths.
170pub 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
202/// Default capture file for this user (latest).
203pub fn default_capture_path() -> Result<PathBuf, CliError> {
204    Ok(xdg::mitm_capture_dir()?.join("capture.json"))
205}
206
207/// Status of MITM readiness + capture counts.
208pub 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
223/// List captured requests.
224pub 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
255/// Get one exchange by id.
256pub 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
268/// Export HAR 1.2 JSON (hand-built; no Python).
269pub 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    // ISO-ish without full chrono dependency API — use time crate if available.
351    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
360/// List unique hosts.
361pub 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
375/// Discover REST/GraphQL-ish endpoints from capture.
376pub 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
417/// Import CDP-style network events (array of {method,url,status,...}) into capture.
418pub 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
484/// Shared capture for optional in-process proxy (thread-safe).
485pub type SharedCapture = Arc<Mutex<MitmCapture>>;
486
487/// Create shared capture bound to default path.
488pub 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
493/// One-shot MITM proxy on `127.0.0.1:0` using hudsucker + local CA.
494///
495/// Runs until `seconds` elapse, then shuts down and persists the capture.
496/// Never binds `0.0.0.0`.
497pub 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    // Bind ephemeral: ask OS for port 0 via std listener, then rebuild with that port.
599    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}