Skip to main content

browser_automation_cli/
mitm_local.rs

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