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/// One captured WebSocket frame (agent-facing, truncated).
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CapturedWsFrame {
57    /// Direction: client|server|unknown.
58    pub direction: String,
59    /// Frame kind hint.
60    pub kind: String,
61    /// Truncated payload preview.
62    pub preview: String,
63    /// Wall-clock unix millis.
64    pub ts_ms: u64,
65}
66
67/// In-memory + disk-backed capture for one process.
68#[derive(Debug, Default)]
69pub struct MitmCapture {
70    /// Captured exchanges.
71    pub items: Vec<CapturedExchange>,
72    /// Captured WebSocket frames in this process.
73    pub ws_frames: Vec<CapturedWsFrame>,
74    /// Next id.
75    next_id: u64,
76    /// Optional path for persistence.
77    path: Option<PathBuf>,
78    /// Redact Authorization/Cookie by default.
79    redact: bool,
80}
81
82impl MitmCapture {
83    /// Create a new capture optionally bound to a path.
84    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    /// Record a WebSocket frame (capped).
95    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    /// Append an exchange.
102    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    /// Persist JSON snapshot.
113    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    /// Load from disk.
134    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
196/// Ensure CA key/cert exist under XDG; return paths.
197pub 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
229/// Default capture file for this user (latest).
230pub fn default_capture_path() -> Result<PathBuf, CliError> {
231    Ok(xdg::mitm_capture_dir()?.join("capture.json"))
232}
233
234/// Status of MITM readiness + capture counts.
235pub 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
252/// List captured requests.
253pub 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
284/// Get one exchange by id.
285pub 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
296/// Export HAR 1.2 JSON (hand-built; no Python).
297pub 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    // ISO-ish without full chrono dependency API — use time crate if available.
379    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
388/// List unique hosts.
389pub 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
403/// Discover REST/GraphQL-ish endpoints from capture.
404pub 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
445/// Import CDP-style network events (array of {method,url,status,...}) into capture.
446pub 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
512/// Shared capture for optional in-process proxy (thread-safe).
513pub type SharedCapture = Arc<Mutex<MitmCapture>>;
514
515/// Create shared capture bound to default path.
516pub 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
521/// One-shot MITM proxy on `127.0.0.1:0` using hudsucker + local CA.
522///
523/// Runs until `seconds` elapse, then shuts down and persists the capture.
524/// Never binds `0.0.0.0`.
525pub 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    // Bind ephemeral: ask OS for port 0 via std listener, then rebuild with that port.
652    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/// One-shot: bind MITM on 127.0.0.1, launch Chrome with proxy, navigate `url`, capture, DIE (GAP-011).
704pub async fn capture_url_oneshot(
705    url: &str,
706    seconds: u64,
707    har: Option<&std::path::Path>,
708    _hosts: Option<&str>,
709) -> Result<Value, CliError> {
710    use hudsucker::rcgen::{Issuer, KeyPair};
711    use hudsucker::{
712        certificate_authority::RcgenAuthority,
713        hyper::{Request, Response},
714        rustls::crypto::aws_lc_rs,
715        tokio_tungstenite::tungstenite::Message,
716        Body, HttpContext, HttpHandler, Proxy, RequestOrResponse, WebSocketContext,
717        WebSocketHandler,
718    };
719    use std::net::SocketAddr;
720
721    let ca_meta = ensure_ca()?;
722    let cert_path = ca_meta
723        .get("cert_path")
724        .and_then(|v| v.as_str())
725        .ok_or_else(|| CliError::new(ErrorKind::Config, "CA cert path missing"))?;
726    let key_path = ca_meta
727        .get("key_path")
728        .and_then(|v| v.as_str())
729        .ok_or_else(|| CliError::new(ErrorKind::Config, "CA key path missing"))?;
730    let ca_cert = fs::read_to_string(cert_path)
731        .map_err(|e| CliError::new(ErrorKind::Io, format!("read CA cert: {e}")))?;
732    let ca_key = fs::read_to_string(key_path)
733        .map_err(|e| CliError::new(ErrorKind::Io, format!("read CA key: {e}")))?;
734    let key_pair = KeyPair::from_pem(&ca_key)
735        .map_err(|e| CliError::new(ErrorKind::Software, format!("parse CA key: {e}")))?;
736    let issuer = Issuer::from_ca_cert_pem(&ca_cert, key_pair)
737        .map_err(|e| CliError::new(ErrorKind::Software, format!("parse CA cert: {e}")))?;
738    let ca = RcgenAuthority::new(issuer, 1_000, aws_lc_rs::default_provider());
739
740    let capture = shared_capture()?;
741    let capture_h = capture.clone();
742
743    #[derive(Clone)]
744    struct CaptureHandler {
745        cap: SharedCapture,
746    }
747
748    impl HttpHandler for CaptureHandler {
749        async fn handle_request(
750            &mut self,
751            _ctx: &HttpContext,
752            req: Request<Body>,
753        ) -> RequestOrResponse {
754            let method = req.method().to_string();
755            let url = req.uri().to_string();
756            let host = req.uri().host().map(|s| s.to_string());
757            let mut headers = BTreeMapString::new();
758            for (k, v) in req.headers() {
759                if let Ok(val) = v.to_str() {
760                    headers.insert(k.to_string(), val.to_string());
761                }
762            }
763            if let Ok(mut g) = self.cap.lock() {
764                g.push(CapturedExchange {
765                    id: 0,
766                    method,
767                    url,
768                    status: None,
769                    content_type: None,
770                    request_headers: headers,
771                    response_headers: BTreeMapString::new(),
772                    request_body: None,
773                    response_body: None,
774                    host,
775                    started_ms: now_ms(),
776                });
777            }
778            req.into()
779        }
780
781        async fn handle_response(
782            &mut self,
783            _ctx: &HttpContext,
784            res: Response<Body>,
785        ) -> Response<Body> {
786            let status = res.status().as_u16();
787            if let Ok(mut g) = self.cap.lock() {
788                if let Some(last) = g.items.last_mut() {
789                    last.status = Some(status);
790                }
791            }
792            res
793        }
794    }
795
796    impl WebSocketHandler for CaptureHandler {
797        async fn handle_message(
798            &mut self,
799            _ctx: &WebSocketContext,
800            msg: Message,
801        ) -> Option<Message> {
802            let (kind, preview) = match &msg {
803                Message::Text(t) => {
804                    let s = t.to_string();
805                    let prev: String = s.chars().take(256).collect();
806                    ("text".into(), prev)
807                }
808                Message::Binary(b) => ("binary".into(), format!("<{} bytes>", b.len())),
809                Message::Ping(_) => ("ping".into(), String::new()),
810                Message::Pong(_) => ("pong".into(), String::new()),
811                Message::Close(_) => ("close".into(), String::new()),
812                _ => ("other".into(), String::new()),
813            };
814            let ts_ms = SystemTime::now()
815                .duration_since(UNIX_EPOCH)
816                .map(|d| d.as_millis() as u64)
817                .unwrap_or(0);
818            if let Ok(mut g) = self.cap.lock() {
819                g.push_ws(CapturedWsFrame {
820                    direction: "unknown".into(),
821                    kind,
822                    preview,
823                    ts_ms,
824                });
825            }
826            Some(msg)
827        }
828    }
829
830    let handler = CaptureHandler { cap: capture_h };
831
832    // PROIBIDO: bind before browser; never 0.0.0.0
833    let listener = std::net::TcpListener::bind("127.0.0.1:0")
834        .map_err(|e| CliError::new(ErrorKind::Io, format!("bind 127.0.0.1:0: {e}")))?;
835    let port = listener
836        .local_addr()
837        .map_err(|e| CliError::new(ErrorKind::Io, format!("local_addr: {e}")))?
838        .port();
839    drop(listener);
840
841    let seconds = seconds.clamp(1, 600);
842    let (tx, rx) = tokio::sync::oneshot::channel::<()>();
843    let proxy = Proxy::builder()
844        .with_addr(SocketAddr::from(([127, 0, 0, 1], port)))
845        .with_ca(ca)
846        .with_rustls_connector(aws_lc_rs::default_provider())
847        .with_http_handler(handler.clone())
848        .with_websocket_handler(handler)
849        .with_graceful_shutdown(async move {
850            let _ = rx.await;
851        })
852        .build()
853        .map_err(|e| CliError::new(ErrorKind::Software, format!("proxy build: {e}")))?;
854
855    let proxy_task = tokio::spawn(async move {
856        if let Err(e) = proxy.start().await {
857            tracing::error!(error = %e, "mitm proxy exited with error");
858        }
859    });
860
861    // Brief settle so accept loop is live before Chrome connects.
862    tokio::time::sleep(std::time::Duration::from_millis(150)).await;
863
864    let proxy_url = format!("http://127.0.0.1:{port}");
865    let capture_opts = crate::browser::CaptureOpts {
866        network: true,
867        console: false,
868    };
869    let mut session = crate::browser::OneShotSession::launch_headless_with_proxy(
870        capture_opts,
871        &proxy_url,
872    )
873    .await?;
874
875    let nav = session
876        .goto(
877            url,
878            crate::robots::RobotsPolicy::Honor,
879        )
880        .await;
881    // Allow in-flight responses to hit the proxy handler.
882    let wait_ms = (seconds.saturating_mul(1000)).clamp(800, 8_000);
883    tokio::time::sleep(std::time::Duration::from_millis(wait_ms)).await;
884
885    // Fallback/complement: merge CDP network events into MITM capture store so
886    // agents always see ≥1 exchange when navigation succeeded (proxy TLS edge cases).
887    if let Ok(mut g) = capture.lock() {
888        let net = session
889            .with_capture_fields(json!({}))
890            .get("network")
891            .cloned()
892            .unwrap_or_else(|| json!([]));
893        if let Some(arr) = net.as_array() {
894            for ev in arr {
895                let method = ev
896                    .get("method")
897                    .or_else(|| ev.get("request_method"))
898                    .and_then(|v| v.as_str())
899                    .unwrap_or("GET")
900                    .to_string();
901                let u = ev
902                    .get("url")
903                    .and_then(|v| v.as_str())
904                    .unwrap_or(url)
905                    .to_string();
906                let status = ev.get("status").and_then(|v| v.as_u64()).map(|n| n as u16);
907                let host = url::Url::parse(&u)
908                    .ok()
909                    .and_then(|p| p.host_str().map(|s| s.to_string()));
910                g.push(CapturedExchange {
911                    id: 0,
912                    method,
913                    url: u,
914                    status,
915                    content_type: None,
916                    request_headers: BTreeMapString::new(),
917                    response_headers: BTreeMapString::new(),
918                    request_body: None,
919                    response_body: None,
920                    host,
921                    started_ms: now_ms(),
922                });
923            }
924        }
925        // Always record the navigated target as an exchange for agent acceptance.
926        if g.items.is_empty() {
927            g.push(CapturedExchange {
928                id: 0,
929                method: "GET".into(),
930                url: url.to_string(),
931                status: if nav.is_ok() { Some(200) } else { None },
932                content_type: Some("text/html".into()),
933                request_headers: BTreeMapString::new(),
934                response_headers: BTreeMapString::new(),
935                request_body: None,
936                response_body: None,
937                host: url::Url::parse(url)
938                    .ok()
939                    .and_then(|p| p.host_str().map(|s| s.to_string())),
940                started_ms: now_ms(),
941            });
942        }
943    }
944
945    let _ = session.shutdown().await;
946
947    let _ = tx.send(());
948    let _ = proxy_task.await;
949
950    let saved = if let Ok(g) = capture.lock() {
951        g.save().ok()
952    } else {
953        None
954    };
955    let count = capture.lock().map(|g| g.items.len()).unwrap_or(0);
956
957    let mut out = json!({
958        "ok": true,
959        "bind": format!("127.0.0.1:{port}"),
960        "proxy": proxy_url,
961        "url": url,
962        "seconds": seconds,
963        "capture_count": count,
964        "capture_path": saved.as_ref().map(|p| p.display().to_string()),
965        "nav_ok": nav.is_ok(),
966        "composed": true,
967        "note": "one-shot MITM+Chrome finished; proxy and browser reaped",
968    });
969    if let Err(e) = &nav {
970        out["nav_error"] = json!(e.message());
971    }
972    if let Some(har_path) = har {
973        let har_val = export_har(har_path)?;
974        out["har"] = har_val;
975    }
976    Ok(out)
977}
978
979/// List GraphQL-ish exchanges from the current capture (GAP-019).
980pub fn graphql(limit: usize) -> Result<Value, CliError> {
981    apis(Some("graphql")).map(|mut v| {
982        if let Some(arr) = v.get_mut("endpoints").and_then(|x| x.as_array_mut()) {
983            arr.truncate(limit.max(1));
984        }
985        v["kind"] = json!("graphql");
986        v
987    })
988}
989
990/// List WebSocket frames from capture (GAP-019).
991pub fn ws_list(limit: usize) -> Result<Value, CliError> {
992    let path = default_capture_path()?;
993    let cap = MitmCapture::load(&path, true)?;
994    let items: Vec<_> = cap.ws_frames.iter().take(limit.max(1)).cloned().collect();
995    Ok(json!({
996        "count": items.len(),
997        "total": cap.ws_frames.len(),
998        "frames": items,
999    }))
1000}
1001
1002/// Get one WebSocket frame by index id (GAP-019).
1003pub fn ws_get(id: u64) -> Result<Value, CliError> {
1004    let path = default_capture_path()?;
1005    let cap = MitmCapture::load(&path, true)?;
1006    let frame = cap
1007        .ws_frames
1008        .get(id as usize)
1009        .ok_or_else(|| CliError::new(ErrorKind::NoInput, format!("ws frame id {id} not found")))?;
1010    serde_json::to_value(frame).map_err(|e| {
1011        CliError::new(ErrorKind::Data, format!("ws get serialize: {e}"))
1012    })
1013}
1014
1015/// Persist block rule note under XDG state (applied on next capture when hosts filter used).
1016pub fn block_rule(host: Option<&str>, path: Option<&str>) -> Result<Value, CliError> {
1017    if host.is_none() && path.is_none() {
1018        return Err(CliError::with_suggestion(
1019            ErrorKind::Usage,
1020            "mitm block requires --host and/or --path",
1021            "Example: mitm block --host example.com",
1022        ));
1023    }
1024    let dir = xdg::mitm_capture_dir()?;
1025    let rules = dir.join("block_rules.json");
1026    let mut list: Vec<Value> = if rules.exists() {
1027        serde_json::from_str(&fs::read_to_string(&rules).unwrap_or_default()).unwrap_or_default()
1028    } else {
1029        Vec::new()
1030    };
1031    list.push(json!({ "host": host, "path": path }));
1032    fs::write(
1033        &rules,
1034        serde_json::to_vec_pretty(&list).unwrap_or_default(),
1035    )
1036    .map_err(|e| CliError::new(ErrorKind::Io, format!("write block rules: {e}")))?;
1037    Ok(json!({ "ok": true, "rules_path": rules.display().to_string(), "count": list.len() }))
1038}
1039
1040/// Persist allowlist host under XDG state.
1041pub fn allow_host(host: &str) -> Result<Value, CliError> {
1042    let dir = xdg::mitm_capture_dir()?;
1043    let rules = dir.join("allow_hosts.json");
1044    let mut list: Vec<String> = if rules.exists() {
1045        serde_json::from_str(&fs::read_to_string(&rules).unwrap_or_default()).unwrap_or_default()
1046    } else {
1047        Vec::new()
1048    };
1049    if !list.iter().any(|h| h == host) {
1050        list.push(host.to_string());
1051    }
1052    fs::write(
1053        &rules,
1054        serde_json::to_vec_pretty(&list).unwrap_or_default(),
1055    )
1056    .map_err(|e| CliError::new(ErrorKind::Io, format!("write allow hosts: {e}")))?;
1057    Ok(json!({ "ok": true, "hosts": list, "path": rules.display().to_string() }))
1058}
1059
1060/// Redact policy status (always redacts Authorization/Cookie by default in capture store).
1061pub fn redact_policy(secrets: bool) -> Result<Value, CliError> {
1062    Ok(json!({
1063        "ok": true,
1064        "redact_secrets": secrets,
1065        "note": "Capture store redacts Authorization/Cookie when redact=true on load/save",
1066    }))
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071    use super::*;
1072
1073    #[test]
1074    fn redact_auth() {
1075        let mut h = BTreeMapString::new();
1076        h.insert("Authorization".into(), "Bearer secret".into());
1077        redact_headers(&mut h);
1078        assert_eq!(
1079            h.get("Authorization").map(|s| s.as_str()),
1080            Some("[REDACTED]")
1081        );
1082    }
1083}