Skip to main content

stealthscraper_rs/
proxy.rs

1//! Local MITM TLS-spoofing proxy ([`TlsSpoofingProxy`]) that terminates the
2//! browser's TLS, then re-emits each request through `wreq` with a forged JA4
3//! `ClientHello` and HTTP/2 fingerprint. The upstream client is hot-swappable so
4//! the egress proxy can rotate without relaunching the browser.
5
6use crate::Error;
7use http_body_util::BodyExt;
8use hyper::server::conn::http1;
9use hyper::service::service_fn;
10use hyper::upgrade::Upgraded;
11use hyper::{Method, Request, Response, StatusCode, body::Incoming};
12use hyper_util::rt::TokioIo;
13use rcgen::{CertifiedKey, generate_simple_self_signed};
14use std::net::SocketAddr;
15use std::sync::{Arc, RwLock};
16use tokio::net::TcpListener;
17use tokio_rustls::TlsAcceptor;
18use tokio_rustls::rustls::{
19    ServerConfig, pki_types::CertificateDer, pki_types::PrivatePkcs8KeyDer,
20};
21use tokio_util::sync::CancellationToken;
22use wreq::Client;
23
24/// Shared, hot-swappable handle to the upstream impersonation client.
25///
26/// The inner `Arc<Client>` can be replaced at runtime (see
27/// [`TlsSpoofingProxy::set_upstream_client`]) so the egress proxy can be rotated
28/// without relaunching the browser — Chrome keeps talking to the same local
29/// MITM port while the outbound client changes underneath it. Read guards are
30/// always cloned out and dropped before any `.await`, never held across one.
31type SharedClient = Arc<RwLock<Arc<Client>>>;
32
33/// Build an empty response with the given status.
34///
35/// Infallible by construction (uses [`Response::new`] + `status_mut`, never the
36/// fallible builder), so it is safe to use as a fallback when forwarding fails.
37fn empty_response(status: StatusCode) -> Response<wreq::Body> {
38    let mut response = Response::new(wreq::Body::from(""));
39    *response.status_mut() = status;
40    response
41}
42
43/// Translate an upstream `wreq` response into a streaming `hyper` response.
44///
45/// Copies the upstream status and headers and streams the body. Upstream data is
46/// untrusted: if a header cannot be represented by `hyper` (so the builder
47/// errors), this returns a `502` instead of panicking.
48fn upstream_response(resp: wreq::Response) -> Response<wreq::Body> {
49    let mut builder = Response::builder().status(resp.status().as_u16());
50    for (key, value) in resp.headers() {
51        builder = builder.header(key.as_str(), value.as_bytes());
52    }
53    match builder.body(wreq::Body::wrap_stream(resp.bytes_stream())) {
54        Ok(response) => response,
55        Err(err) => {
56            log::warn!("dropping upstream response with unrepresentable headers: {err}");
57            empty_response(StatusCode::BAD_GATEWAY)
58        }
59    }
60}
61
62/// A silent TLS Man-in-the-Middle (MITM) proxy that injects JA4/TLS fingerprints.
63///
64/// This proxy intercepts HTTP/HTTPS requests from a standard proxy-equipped client
65/// (like Headless Chrome), terminates the TLS connection using self-signed certs via `rcgen`,
66/// and forwards the upstream request utilizing a tightly bound `wreq` client matching the
67/// intended target fingerprint.
68pub struct TlsSpoofingProxy {
69    port: u16,
70    shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
71    cancel_token: CancellationToken,
72    client_slot: SharedClient,
73}
74
75impl Drop for TlsSpoofingProxy {
76    fn drop(&mut self) {
77        self.cancel_token.cancel();
78        if let Some(tx) = self.shutdown_tx.take() {
79            let _ = tx.send(()); // Send shutdown signal
80        }
81    }
82}
83
84impl TlsSpoofingProxy {
85    /// Binds the proxy to an available local TCP port and spawns the background listener.
86    ///
87    /// # Arguments
88    /// * `impersonate_client` - The configured `wreq` TLS/JA4 impersonation client
89    /// * `debug_mode` - If `true`, logs all intercepted requests and TLS upgrades to stdout
90    pub async fn start(impersonate_client: Client, debug_mode: bool) -> Result<Self, Error> {
91        let addr = SocketAddr::from(([127, 0, 0, 1], 0));
92        let listener = TcpListener::bind(addr).await?;
93        let port = listener.local_addr()?.port();
94
95        if debug_mode {
96            log::info!("TLS spoofing proxy listening on 127.0.0.1:{port}");
97        }
98
99        let client_slot: SharedClient = Arc::new(RwLock::new(Arc::new(impersonate_client)));
100        let client = Arc::clone(&client_slot);
101        let cancel_token = CancellationToken::new();
102        let loop_token = cancel_token.clone();
103
104        let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>();
105
106        tokio::spawn(async move {
107            loop {
108                tokio::select! {
109                    _ = loop_token.cancelled() => {
110                        log::debug!("proxy listener loop cancelled");
111                        break;
112                    }
113                    res = listener.accept() => {
114                        match res {
115                            Ok((stream, addr)) => {
116                                if debug_mode {
117                                    log::debug!("accepted connection from {addr}");
118                                }
119                                let io = TokioIo::new(stream);
120                                let client_clone = Arc::clone(&client);
121                                let conn_token = loop_token.clone();
122
123                                tokio::task::spawn(async move {
124                                    let service_token = conn_token.clone();
125                                    let conn = http1::Builder::new()
126                                        .preserve_header_case(true)
127                                        .title_case_headers(true)
128                                        .serve_connection(io, service_fn(move |req| {
129                                            let req_token = service_token.clone();
130                                            Self::handle_request(req, Arc::clone(&client_clone), req_token, debug_mode)
131                                        }))
132                                        .with_upgrades();
133
134                                    tokio::pin!(conn);
135
136                                    tokio::select! {
137                                        res = &mut conn => {
138                                            if let Err(err) = res {
139                                                log::warn!("failed to serve connection: {err:?}");
140                                            }
141                                        }
142                                        _ = conn_token.cancelled() => {
143                                            conn.as_mut().graceful_shutdown();
144                                        }
145                                    }
146                                });
147                            }
148                            Err(e) => {
149                                log::warn!("accept failed: {e}");
150                            }
151                        }
152                    }
153                    _ = &mut shutdown_rx => {
154                        log::debug!("proxy listener shutting down");
155                        break;
156                    }
157                }
158            }
159        });
160
161        Ok(Self {
162            port,
163            shutdown_tx: Some(shutdown_tx),
164            cancel_token,
165            client_slot,
166        })
167    }
168
169    /// Returns the active local loopback port dynamically assigned during `start()`.
170    pub fn port(&self) -> u16 {
171        self.port
172    }
173
174    /// Hot-swap the upstream impersonation client (e.g. to rotate the egress proxy).
175    ///
176    /// In-flight requests finish on the previous client; subsequent requests use
177    /// `client`. The local MITM port is unchanged, so the browser needs no restart.
178    pub fn set_upstream_client(&self, client: Client) {
179        *self.client_slot.write().expect("client_slot lock poisoned") = Arc::new(client);
180    }
181
182    async fn handle_request(
183        mut req: Request<Incoming>,
184        client: SharedClient,
185        token: CancellationToken,
186        debug_mode: bool,
187    ) -> Result<Response<wreq::Body>, std::convert::Infallible> {
188        if Method::CONNECT == req.method() {
189            let target_host = req.uri().host().unwrap_or("").to_string();
190
191            if debug_mode {
192                log::debug!("[MITM] intercepting TLS upgrade for {target_host}");
193            }
194
195            tokio::task::spawn(async move {
196                match hyper::upgrade::on(&mut req).await {
197                    Ok(upgraded) => {
198                        let _ =
199                            Self::handle_tunnel(upgraded, target_host, client, token, debug_mode)
200                                .await;
201                    }
202                    Err(e) => log::warn!("upgrade error: {e}"),
203                }
204            });
205
206            Ok(empty_response(StatusCode::OK))
207        } else {
208            let hyper_uri = req.uri().to_string();
209            let method = req.method().clone();
210
211            // Untrusted client method: fall back to 400 rather than panicking.
212            let Ok(wreq_method) = wreq::Method::from_bytes(method.as_str().as_bytes()) else {
213                return Ok(empty_response(StatusCode::BAD_REQUEST));
214            };
215
216            // Snapshot the current upstream client; drop the guard before awaiting.
217            let client = client.read().expect("client_slot lock poisoned").clone();
218
219            // Build outbound request
220            let mut req_builder = client.request(wreq_method, hyper_uri.clone());
221
222            for (key, value) in req.headers() {
223                req_builder = req_builder.header(key.as_str(), value.as_bytes());
224            }
225
226            // Stream incoming body bytes dynamically
227            let req_body = req.into_body().into_data_stream();
228            req_builder = req_builder.body(wreq::Body::wrap_stream(req_body));
229
230            match req_builder.send().await {
231                Ok(resp) => {
232                    if debug_mode {
233                        log::debug!("[HTTP] {method} {hyper_uri} -> {}", resp.status());
234                    }
235                    Ok(upstream_response(resp))
236                }
237                Err(e) => {
238                    if debug_mode {
239                        log::debug!("[HTTP ERROR] {method} {hyper_uri} -> {e:?}");
240                    }
241                    Ok(empty_response(StatusCode::BAD_GATEWAY))
242                }
243            }
244        }
245    }
246
247    async fn handle_tunnel(
248        upgraded: Upgraded,
249        target_host: String,
250        client: SharedClient,
251        token: CancellationToken,
252        debug_mode: bool,
253    ) -> Result<(), Error> {
254        let subject_alt_names = vec![target_host.clone()];
255
256        // Spawn blocking for CPU-bound cert generation. The SAN comes from the
257        // (untrusted) CONNECT target host, so a generation failure is mapped to a
258        // TLS error rather than panicking the tunnel task.
259        let CertifiedKey { cert, signing_key } =
260            tokio::task::spawn_blocking(move || generate_simple_self_signed(subject_alt_names))
261                .await
262                .map_err(|e| Error::JoinError(format!("Join error: {e}")))?
263                .map_err(|e| Error::TlsError(format!("self-signed cert generation failed: {e}")))?;
264
265        let cert_der = cert.der().to_vec();
266        let key_der = signing_key.serialize_der();
267
268        let single_cert = CertificateDer::from(cert_der);
269        let private_key = PrivatePkcs8KeyDer::from(key_der).into();
270
271        let mut config = ServerConfig::builder()
272            .with_no_client_auth()
273            .with_single_cert(vec![single_cert], private_key)
274            .map_err(|e| Error::TlsError(format!("TLS config error: {}", e)))?;
275
276        config.alpn_protocols = vec![b"http/1.1".to_vec()];
277
278        let acceptor = TlsAcceptor::from(Arc::new(config));
279
280        let io = TokioIo::new(upgraded);
281        let tls_stream = acceptor
282            .accept(io)
283            .await
284            .map_err(|e| Error::TlsError(format!("TLS Accept error: {}", e)))?;
285
286        let tls_io = TokioIo::new(tls_stream);
287        let conn_token = token.clone();
288
289        let conn = http1::Builder::new()
290            .preserve_header_case(true)
291            .title_case_headers(true)
292            .serve_connection(
293                tls_io,
294                service_fn(move |inner_req| {
295                    let host = target_host.clone();
296                    let client_ref = Arc::clone(&client);
297                    async move {
298                        Self::forward_tls_request(inner_req, host, client_ref, debug_mode).await
299                    }
300                }),
301            );
302
303        tokio::pin!(conn);
304
305        tokio::select! {
306            res = &mut conn => {
307                if let Err(err) = res {
308                    // Silently ignore harmless TCP teardowns (like incomplete headers or HTTP keep-alive timeouts)
309                    // These naturally occur when the parent rs-arlo client pauses for ~30 seconds (IMAP Auth)
310                    let err_str = format!("{err:?}");
311                    if !err_str.contains("Parse(Method)") && !err_str.contains("IncompleteMessage") {
312                        log::warn!("[TLS] connection drop: {err:?}");
313                    }
314                }
315            }
316            _ = conn_token.cancelled() => {
317                conn.as_mut().graceful_shutdown();
318            }
319        }
320
321        Ok(())
322    }
323
324    async fn forward_tls_request(
325        req: Request<Incoming>,
326        host: String,
327        client: SharedClient,
328        debug_mode: bool,
329    ) -> Result<Response<wreq::Body>, std::convert::Infallible> {
330        let uri = format!(
331            "https://{}{}",
332            host,
333            req.uri()
334                .path_and_query()
335                .map(|x| x.as_str())
336                .unwrap_or("/")
337        );
338
339        let method = req.method().clone();
340
341        // Untrusted client method: fall back to 400 rather than panicking.
342        let Ok(wreq_method) = wreq::Method::from_bytes(method.as_str().as_bytes()) else {
343            return Ok(empty_response(StatusCode::BAD_REQUEST));
344        };
345
346        // Snapshot the current upstream client; drop the guard before awaiting.
347        let client = client.read().expect("client_slot lock poisoned").clone();
348
349        let mut req_builder = client.request(wreq_method, uri.clone());
350
351        for (key, value) in req.headers() {
352            if key != "host" {
353                req_builder = req_builder.header(key.as_str(), value.as_bytes());
354            }
355        }
356
357        let req_body = req.into_body().into_data_stream();
358        req_builder = req_builder.body(wreq::Body::wrap_stream(req_body));
359
360        match req_builder.send().await {
361            Ok(resp) => {
362                if debug_mode {
363                    log::debug!("[TLS] {method} {uri} -> {}", resp.status());
364                }
365                Ok(upstream_response(resp))
366            }
367            Err(e) => {
368                if debug_mode {
369                    log::debug!("[TLS ERROR] {method} {uri} -> {e:?}");
370                }
371                Ok(empty_response(StatusCode::BAD_GATEWAY))
372            }
373        }
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[tokio::test]
382    async fn test_proxy_initialization() {
383        let client = wreq::Client::builder()
384            .build()
385            .expect("Failed to build client");
386
387        let proxy = TlsSpoofingProxy::start(client, false)
388            .await
389            .expect("Failed to start proxy");
390
391        // Assert a port was dynamically assigned
392        assert!(proxy.port() > 0);
393
394        // The Drop impl should trigger smooth shutdown
395        drop(proxy);
396    }
397
398    #[tokio::test]
399    async fn test_proxy_http_and_https_forwarding() {
400        // Rustls 0.23+ requires an explicit process-level crypto provider,
401        // since reqwest doesn't automatically install it when used as a library.
402        let _ = tokio_rustls::rustls::crypto::ring::default_provider().install_default();
403
404        let client = wreq::Client::builder()
405            .build()
406            .expect("Failed to build client");
407
408        let proxy = TlsSpoofingProxy::start(client, false)
409            .await
410            .expect("Failed to start proxy");
411
412        let port = proxy.port();
413
414        // Use a standard reqwest client to fire a request AT the proxy
415        let req_client = reqwest::Client::builder()
416            .proxy(reqwest::Proxy::all(format!("http://127.0.0.1:{}", port)).unwrap())
417            .danger_accept_invalid_certs(true) // accept the local MITM cert
418            .build()
419            .unwrap();
420
421        // 1. Test standard HTTP forwarding
422        let http_resp = req_client.get("http://example.com").send().await;
423        assert!(http_resp.is_ok());
424        let http_status = http_resp.unwrap().status();
425        assert!(http_status.is_success() || http_status.is_redirection());
426
427        // 2. Test TLS Upgrading / HTTPS CONNECT handling
428        let https_resp = req_client.get("https://example.com").send().await;
429        assert!(https_resp.is_ok());
430        let https_status = https_resp.unwrap().status();
431
432        // We accept success, redirects, or 502 (if our proxy fails to forward cleanly due to networking restrictions, but the tunnel was built)
433        assert!(
434            https_status.is_success()
435                || https_status.is_redirection()
436                || https_status.as_u16() == 502
437        );
438    }
439
440    #[tokio::test]
441    async fn test_proxy_shutdown() {
442        let client = wreq::Client::builder().build().unwrap();
443        let proxy = TlsSpoofingProxy::start(client, false).await.unwrap();
444        let port = proxy.port();
445
446        let req_client = reqwest::Client::builder()
447            .proxy(reqwest::Proxy::all(format!("http://127.0.0.1:{}", port)).unwrap())
448            .build()
449            .unwrap();
450
451        // Drop the proxy to trigger cancellation token and close listener
452        drop(proxy);
453
454        // Give it a tiny bit of time for tokio shutdown process to finalize the bind release
455        tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
456
457        // Firing request should fail due to proxy being down
458        let res = req_client.get("http://example.com").send().await;
459        assert!(res.is_err(), "Request succeeded but proxy should be down");
460    }
461
462    #[tokio::test]
463    async fn test_proxy_502_error_flow() {
464        // Mock upstream server that fails or drops connections
465        let mut server = mockito::Server::new_async().await;
466
467        // Mock an upstream returning 500
468        let _mock = server
469            .mock("GET", "/")
470            .with_status(500)
471            .create_async()
472            .await;
473
474        let client = wreq::Client::builder().build().unwrap();
475        let proxy = TlsSpoofingProxy::start(client, false).await.unwrap();
476        let port = proxy.port();
477
478        let req_client = reqwest::Client::builder()
479            .proxy(reqwest::Proxy::all(format!("http://127.0.0.1:{}", port)).unwrap())
480            .build()
481            .unwrap();
482
483        // Fire request to the mocked upstream via our proxy
484        let url = server.url();
485        let res = req_client.get(&url).send().await.unwrap();
486
487        // The mock returned 500, but our proxy correctly forwarded the HTTP response
488        assert_eq!(res.status().as_u16(), 500);
489
490        // Now test routing to a truly invalid host to trigger internal 502 behavior
491        let bad_url = format!("http://127.0.0.1:{}", server.url().len()); // an invalid or closed port might work, but let's test a non-existent port
492        let res2 = req_client.get(&bad_url).send().await;
493
494        // Either the hyper proxy returns 502 OR the reqwest client surfaces the connection refused
495        if let Ok(response) = res2 {
496            assert_eq!(response.status().as_u16(), 502);
497        }
498    }
499}