Skip to main content

ghostwire/
client.rs

1//! Core `Ghostwire` client – wraps `reqwest` with Cloudflare bypass logic.
2
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use bytes::Bytes;
7use reqwest::header::{HeaderMap, HeaderValue, ORIGIN, REFERER};
8use reqwest::{Method, Response};
9use tokio::sync::Mutex;
10use tracing::{debug, instrument, warn};
11use url::Url;
12
13use crate::captcha::{CaptchaConfig, CaptchaKind, make_solver};
14use crate::challenge::js_interp::JsInterpreter;
15use crate::challenge::turnstile::CloudflareTurnstile;
16use crate::challenge::v1::{CloudflareV1, V1ChallengeKind};
17use crate::challenge::v2::CloudflareV2;
18use crate::challenge::v3::CloudflareV3;
19use crate::error::{GhostwireError, Result};
20use crate::proxy_manager::{ProxyManager, RotationStrategy};
21use crate::stealth::{StealthConfig, StealthState};
22use crate::user_agent::{UserAgent, UserAgentOptions};
23
24// ── Builder ───────────────────────────────────────────────────────────────────
25
26/// Fluent builder for `Ghostwire`.
27#[derive(Debug, Clone)]
28pub struct GhostwireBuilder {
29    // Challenge control
30    pub disable_v1: bool,
31    pub disable_v2: bool,
32    pub disable_v3: bool,
33    pub disable_turnstile: bool,
34
35    /// Optional fixed delay before challenge submission (seconds).
36    pub delay: Option<f64>,
37
38    /// Maximum number of challenge-solve iterations before giving up.
39    pub solve_depth: usize,
40
41    /// Optional captcha provider configuration.
42    pub captcha: Option<CaptchaConfig>,
43
44    /// Double-down: re-request after a captcha to see if cfuid alone suffices.
45    pub double_down: bool,
46
47    /// Stealth mode configuration.
48    pub stealth: StealthConfig,
49
50    /// User-agent selection options.
51    pub user_agent_opts: UserAgentOptions,
52
53    // Proxy pool
54    pub proxies: Vec<String>,
55    pub proxy_rotation: RotationStrategy,
56    pub proxy_ban_secs: u64,
57
58    // Session refresh
59    pub session_refresh_secs: u64,
60    pub auto_refresh_on_403: bool,
61    pub max_403_retries: usize,
62
63    /// Minimum seconds between consecutive requests.
64    pub min_request_interval_secs: f64,
65
66    /// Print debug information to the log.
67    pub debug: bool,
68
69    /// JavaScript interpreter to use when solving v3 VM challenges.
70    ///
71    /// Defaults to [`JsInterpreter::Auto`], which tries boa → v8 → node → bun
72    /// in order and falls back to a heuristic answer if none are available.
73    pub js_interpreter: JsInterpreter,
74}
75
76impl Default for GhostwireBuilder {
77    fn default() -> Self {
78        GhostwireBuilder {
79            disable_v1: false,
80            disable_v2: false,
81            disable_v3: false,
82            disable_turnstile: false,
83            delay: None,
84            solve_depth: 3,
85            captcha: None,
86            double_down: true,
87            stealth: StealthConfig::default(),
88            user_agent_opts: UserAgentOptions {
89                desktop: true,
90                mobile: true,
91                allow_brotli: false,
92                ..Default::default()
93            },
94            proxies: Vec::new(),
95            proxy_rotation: RotationStrategy::Sequential,
96            proxy_ban_secs: 300,
97            session_refresh_secs: 3600,
98            auto_refresh_on_403: true,
99            max_403_retries: 3,
100            min_request_interval_secs: 1.0,
101            debug: false,
102            js_interpreter: JsInterpreter::Auto,
103        }
104    }
105}
106
107impl GhostwireBuilder {
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    pub fn debug(mut self, v: bool) -> Self {
113        self.debug = v;
114        self
115    }
116    pub fn disable_v1(mut self, v: bool) -> Self {
117        self.disable_v1 = v;
118        self
119    }
120    pub fn disable_v2(mut self, v: bool) -> Self {
121        self.disable_v2 = v;
122        self
123    }
124    pub fn disable_v3(mut self, v: bool) -> Self {
125        self.disable_v3 = v;
126        self
127    }
128    pub fn disable_turnstile(mut self, v: bool) -> Self {
129        self.disable_turnstile = v;
130        self
131    }
132    pub fn delay(mut self, v: f64) -> Self {
133        self.delay = Some(v);
134        self
135    }
136    pub fn solve_depth(mut self, v: usize) -> Self {
137        self.solve_depth = v;
138        self
139    }
140    pub fn captcha(mut self, v: CaptchaConfig) -> Self {
141        self.captcha = Some(v);
142        self
143    }
144    pub fn double_down(mut self, v: bool) -> Self {
145        self.double_down = v;
146        self
147    }
148    pub fn stealth(mut self, v: StealthConfig) -> Self {
149        self.stealth = v;
150        self
151    }
152    pub fn user_agent_opts(mut self, v: UserAgentOptions) -> Self {
153        self.user_agent_opts = v;
154        self
155    }
156    pub fn add_proxy(mut self, v: impl Into<String>) -> Self {
157        self.proxies.push(v.into());
158        self
159    }
160    pub fn proxies(mut self, v: Vec<String>) -> Self {
161        self.proxies = v;
162        self
163    }
164    pub fn proxy_rotation(mut self, v: RotationStrategy) -> Self {
165        self.proxy_rotation = v;
166        self
167    }
168    pub fn proxy_ban_secs(mut self, v: u64) -> Self {
169        self.proxy_ban_secs = v;
170        self
171    }
172    pub fn session_refresh_secs(mut self, v: u64) -> Self {
173        self.session_refresh_secs = v;
174        self
175    }
176    pub fn auto_refresh_on_403(mut self, v: bool) -> Self {
177        self.auto_refresh_on_403 = v;
178        self
179    }
180    pub fn max_403_retries(mut self, v: usize) -> Self {
181        self.max_403_retries = v;
182        self
183    }
184    pub fn min_request_interval_secs(mut self, v: f64) -> Self {
185        self.min_request_interval_secs = v;
186        self
187    }
188
189    /// Set the JavaScript interpreter used to solve Cloudflare v3 VM challenges.
190    ///
191    /// ```rust,no_run
192    /// use ghostwire::Ghostwire;
193    /// use ghostwire::challenge::JsInterpreter;
194    ///
195    /// let client = Ghostwire::builder()
196    ///     .js_interpreter(JsInterpreter::Node)
197    ///     .build()
198    ///     .unwrap();
199    /// ```
200    pub fn js_interpreter(mut self, interp: JsInterpreter) -> Self {
201        self.js_interpreter = interp;
202        self
203    }
204
205    /// Consume the builder and produce a `Ghostwire`.
206    pub fn build(self) -> Result<Ghostwire> {
207        let ua = UserAgent::new(&self.user_agent_opts)?;
208
209        let default_headers = ua.header_map();
210
211        let client = reqwest::Client::builder()
212            .default_headers(default_headers)
213            .cookie_store(true)
214            .gzip(true)
215            .brotli(self.user_agent_opts.allow_brotli)
216            .deflate(true)
217            .build()
218            .map_err(GhostwireError::HttpError)?;
219
220        let proxy_manager = ProxyManager::new(
221            self.proxies.clone(),
222            self.proxy_rotation.clone(),
223            self.proxy_ban_secs,
224        );
225
226        let stealth_state = StealthState::new(self.stealth.clone());
227        let js_interpreter = self.js_interpreter.clone();
228
229        Ok(Ghostwire {
230            client,
231            user_agent: ua,
232            config: Arc::new(self),
233            js_interpreter,
234            proxy_manager: Arc::new(Mutex::new(proxy_manager)),
235            stealth: Arc::new(Mutex::new(stealth_state)),
236            solve_depth: 0,
237            session_start: Instant::now(),
238            request_count: 0,
239            last_request: None,
240            retry_403_count: 0,
241        })
242    }
243}
244
245// ── Ghostwire ──────────────────────────────────────────────────────────────
246
247/// A Cloudflare-aware async HTTP client.
248pub struct Ghostwire {
249    pub(crate) client: reqwest::Client,
250    pub(crate) user_agent: UserAgent,
251    pub(crate) config: Arc<GhostwireBuilder>,
252    pub(crate) js_interpreter: JsInterpreter,
253    #[allow(dead_code)]
254    pub(crate) proxy_manager: Arc<Mutex<ProxyManager>>,
255    pub(crate) stealth: Arc<Mutex<StealthState>>,
256
257    // Runtime state.
258    solve_depth: usize,
259    #[allow(dead_code)]
260    session_start: Instant,
261    #[allow(dead_code)]
262    request_count: u64,
263    last_request: Option<Instant>,
264    retry_403_count: usize,
265}
266
267impl Ghostwire {
268    /// Create a `Ghostwire` with sensible defaults.
269    pub fn new() -> Result<Self> {
270        GhostwireBuilder::new().build()
271    }
272
273    /// Return a fluent builder.
274    pub fn builder() -> GhostwireBuilder {
275        GhostwireBuilder::new()
276    }
277
278    // ── Convenience HTTP methods ──────────────────────────────────────────────
279
280    pub async fn get(&mut self, url: &str) -> Result<Response> {
281        self.request(Method::GET, url, RequestOptions::default())
282            .await
283    }
284
285    pub async fn post_bytes(&mut self, url: &str, body: Bytes) -> Result<Response> {
286        self.request(
287            Method::POST,
288            url,
289            RequestOptions {
290                body_bytes: Some(body),
291                ..Default::default()
292            },
293        )
294        .await
295    }
296
297    pub async fn post_form(&mut self, url: &str, form: Vec<(String, String)>) -> Result<Response> {
298        self.request(
299            Method::POST,
300            url,
301            RequestOptions {
302                form: Some(form),
303                ..Default::default()
304            },
305        )
306        .await
307    }
308
309    // ── Core request dispatch ─────────────────────────────────────────────────
310
311    /// Send an HTTP request, automatically handling Cloudflare challenges.
312    #[instrument(
313        name = "request",
314        skip(self, opts),
315        fields(method = %method, url = %url, depth = self.solve_depth)
316    )]
317    pub async fn request(
318        &mut self,
319        method: Method,
320        url: &str,
321        opts: RequestOptions,
322    ) -> Result<Response> {
323        // Rate-limit consecutive requests.
324        self.throttle().await;
325
326        // Stealth pre-request hook (human-like delay, etc.).
327        {
328            let mut stealth = self.stealth.lock().await;
329            stealth.pre_request().await;
330        }
331
332        // Perform the raw HTTP request.
333        let response = self.raw_request(method.clone(), url, &opts).await?;
334
335        let status = response.status().as_u16();
336        let server = response
337            .headers()
338            .get("server")
339            .and_then(|v| v.to_str().ok())
340            .unwrap_or("")
341            .to_string();
342        let headers_clone = response.headers().clone();
343        let final_url = response.url().clone();
344
345        debug!(method = %method, url = %url, status = status, "response received");
346
347        // ── Loop-protection ──────────────────────────────────────────────────
348        if self.solve_depth >= self.config.solve_depth {
349            let depth = self.solve_depth;
350            self.solve_depth = 0;
351            return Err(GhostwireError::LoopProtection(depth));
352        }
353
354        // Collect body text for challenge detection (consumes the response).
355        let body = response.text().await.map_err(GhostwireError::HttpError)?;
356
357        // ── Turnstile ────────────────────────────────────────────────────────
358        if !self.config.disable_turnstile
359            && CloudflareTurnstile::is_turnstile_challenge(status, &server, &body)
360        {
361            debug!(depth = self.solve_depth + 1, "detected Turnstile challenge");
362            self.solve_depth += 1;
363            return self.handle_turnstile(final_url.as_str(), &body, opts).await;
364        }
365
366        // ── v3 ───────────────────────────────────────────────────────────────
367        if !self.config.disable_v3 && CloudflareV3::is_v3_challenge(status, &server, &body) {
368            debug!(depth = self.solve_depth + 1, "detected v3 challenge");
369            self.solve_depth += 1;
370            return self.handle_v3(final_url.as_str(), &body, opts).await;
371        }
372
373        // ── v2 ───────────────────────────────────────────────────────────────
374        if !self.config.disable_v2 {
375            if CloudflareV2::is_v2_captcha_challenge(status, &server, &body) {
376                self.solve_depth += 1;
377                return self
378                    .handle_v2_captcha(final_url.as_str(), &body, opts)
379                    .await;
380            }
381            if CloudflareV2::is_v2_js_challenge(status, &server, &body) {
382                self.solve_depth += 1;
383                return self.handle_v2_js(final_url.as_str(), &body, opts).await;
384            }
385        }
386
387        // ── v1 ───────────────────────────────────────────────────────────────
388        if !self.config.disable_v1 {
389            match CloudflareV1::classify(status, &server, &body) {
390                Some(V1ChallengeKind::Firewall1020) => {
391                    return Err(GhostwireError::FirewallBlocked);
392                }
393                Some(V1ChallengeKind::NewIUAM) | Some(V1ChallengeKind::NewCaptcha) => {
394                    return Err(GhostwireError::ChallengeError(
395                        "Detected a Cloudflare v2 challenge – configure a captcha provider.".into(),
396                    ));
397                }
398                Some(V1ChallengeKind::IUAM) => {
399                    self.solve_depth += 1;
400                    return self.handle_v1_iuam(final_url.as_str(), &body, opts).await;
401                }
402                Some(V1ChallengeKind::Captcha) => {
403                    self.solve_depth += 1;
404                    return self
405                        .handle_v1_captcha(final_url.as_str(), &body, opts)
406                        .await;
407                }
408                None => {}
409            }
410        }
411
412        // ── 403 auto-refresh ──────────────────────────────────────────────────
413        if status == 403 && self.config.auto_refresh_on_403 {
414            if self.retry_403_count < self.config.max_403_retries {
415                self.retry_403_count += 1;
416                warn!(
417                    retry = self.retry_403_count,
418                    max = self.config.max_403_retries,
419                    "403 received, retrying"
420                );
421                return Box::pin(self.request(method, url, opts)).await;
422            }
423        }
424
425        // No challenge detected – reconstruct response from collected parts.
426        self.solve_depth = 0;
427        self.retry_403_count = 0;
428        build_text_response(status, headers_clone, body)
429    }
430
431    // ── Internal request builder ──────────────────────────────────────────────
432
433    async fn raw_request(
434        &self,
435        method: Method,
436        url: &str,
437        opts: &RequestOptions,
438    ) -> Result<Response> {
439        let mut req = self.client.request(method, url);
440
441        // Per-request extra headers.
442        if let Some(h) = &opts.headers {
443            req = req.headers(h.clone());
444        }
445
446        // Stealth-mode extra headers.
447        let ua_str = self.user_agent.user_agent_string.clone();
448        let mut extra = HeaderMap::new();
449        {
450            let stealth = self.stealth.lock().await;
451            stealth.apply_to_headers(&mut extra, &ua_str);
452        }
453        req = req.headers(extra);
454
455        // Body: form takes precedence over raw bytes.
456        if let Some(form) = &opts.form {
457            req = req.form(form);
458        } else if let Some(body) = opts.body_bytes.clone() {
459            req = req.body(body);
460        }
461
462        // Optional per-request timeout.
463        if let Some(t) = opts.timeout {
464            req = req.timeout(t);
465        }
466
467        // Redirect policy.
468        if opts.follow_redirects == Some(false) {
469            // reqwest's RequestBuilder doesn't expose a per-request redirect
470            // override, so we build a one-shot client with redirects disabled,
471            // then replay the already-configured request through it.
472            let built = req.build().map_err(GhostwireError::HttpError)?;
473            let method = built.method().clone();
474            let url = built.url().clone();
475
476            let no_redirect_client = reqwest::Client::builder()
477                .cookie_store(true)
478                .redirect(reqwest::redirect::Policy::none())
479                .build()
480                .map_err(GhostwireError::HttpError)?;
481
482            return no_redirect_client
483                .request(method, url)
484                .send()
485                .await
486                .map_err(GhostwireError::HttpError);
487        }
488
489        req.send().await.map_err(GhostwireError::HttpError)
490    }
491
492    // ── Throttle ──────────────────────────────────────────────────────────────
493
494    async fn throttle(&mut self) {
495        if let Some(last) = self.last_request {
496            let elapsed = last.elapsed().as_secs_f64();
497            let min = self.config.min_request_interval_secs;
498            if elapsed < min {
499                tokio::time::sleep(Duration::from_secs_f64(min - elapsed)).await;
500            }
501        }
502        self.last_request = Some(Instant::now());
503        self.request_count += 1;
504    }
505
506    // ── Challenge handlers ────────────────────────────────────────────────────
507
508    async fn handle_v1_iuam(
509        &mut self,
510        page_url: &str,
511        body: &str,
512        _opts: RequestOptions,
513    ) -> Result<Response> {
514        let delay = self
515            .config
516            .delay
517            .unwrap_or_else(|| CloudflareV1::extract_delay(body).unwrap_or(5.0));
518        tokio::time::sleep(Duration::from_secs_f64(delay)).await;
519
520        // Fallback answer: domain length (works for the simple arithmetic IUAM).
521        let domain = Url::parse(page_url)
522            .ok()
523            .and_then(|u| u.host_str().map(|h| h.to_string()))
524            .unwrap_or_default();
525        let answer = domain.len() as f64;
526
527        let (submit_url, form_data) = CloudflareV1::extract_iuam_params(body, page_url, answer)?;
528
529        let parsed = Url::parse(page_url)?;
530        let origin = format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap_or(""));
531
532        let mut headers = HeaderMap::new();
533        headers.insert(ORIGIN, HeaderValue::from_str(&origin).unwrap());
534        headers.insert(REFERER, HeaderValue::from_str(page_url).unwrap());
535
536        let post_opts = RequestOptions {
537            form: Some(form_data),
538            headers: Some(headers),
539            follow_redirects: Some(true),
540            ..Default::default()
541        };
542
543        Box::pin(self.request(Method::POST, &submit_url, post_opts)).await
544    }
545
546    async fn handle_v1_captcha(
547        &mut self,
548        page_url: &str,
549        body: &str,
550        _opts: RequestOptions,
551    ) -> Result<Response> {
552        let captcha_cfg = self.config.captcha.as_ref().ok_or_else(|| {
553            GhostwireError::CaptchaProviderMissing(
554                "No captcha provider configured for v1 captcha challenge.".into(),
555            )
556        })?;
557
558        if captcha_cfg.provider == "return_response" {
559            return build_text_response(403, HeaderMap::new(), body.to_string());
560        }
561
562        let solver = make_solver(captcha_cfg).ok_or_else(|| {
563            GhostwireError::CaptchaProviderMissing(format!(
564                "Unknown captcha provider: {}",
565                captcha_cfg.provider
566            ))
567        })?;
568
569        // Extract the hCaptcha site key.
570        let site_key = {
571            static RE_SITEKEY: once_cell::sync::Lazy<regex::Regex> =
572                once_cell::sync::Lazy::new(|| {
573                    regex::Regex::new(r#"data-sitekey="([^"]+)""#).unwrap()
574                });
575            RE_SITEKEY
576                .captures(body)
577                .map(|c| c.get(1).unwrap().as_str().to_string())
578                .ok_or_else(|| GhostwireError::CaptchaError("Cannot find site key".into()))?
579        };
580
581        let token = solver
582            .solve(CaptchaKind::HCaptcha, page_url, &site_key, captcha_cfg)
583            .await?;
584
585        // Extract the form action.
586        let uuid = {
587            static RE_FORM: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(
588                || {
589                    regex::Regex::new(
590                        r#"(?s)<form [^>]*?="challenge-form" action="(?P<uuid>[^"]+__cf_chl_f_tk=[^"]+)""#,
591                    )
592                    .unwrap()
593                },
594            );
595            RE_FORM
596                .captures(body)
597                .and_then(|c| c.name("uuid").map(|m| m.as_str().to_string()))
598                .ok_or_else(|| GhostwireError::CaptchaError("Cannot find captcha form".into()))?
599        };
600
601        let parsed = Url::parse(page_url)?;
602        let submit_url = format!(
603            "{}://{}{}",
604            parsed.scheme(),
605            parsed.host_str().unwrap_or(""),
606            html_escape::decode_html_entities(&uuid)
607        );
608
609        let form_data = vec![
610            ("cf-turnstile-response".to_string(), token.clone()),
611            ("h-captcha-response".to_string(), token),
612        ];
613
614        let origin = format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap_or(""));
615        let mut headers = HeaderMap::new();
616        headers.insert(ORIGIN, HeaderValue::from_str(&origin).unwrap());
617        headers.insert(REFERER, HeaderValue::from_str(page_url).unwrap());
618
619        let post_opts = RequestOptions {
620            form: Some(form_data),
621            headers: Some(headers),
622            follow_redirects: Some(true),
623            ..Default::default()
624        };
625
626        Box::pin(self.request(Method::POST, &submit_url, post_opts)).await
627    }
628
629    async fn handle_v2_js(
630        &mut self,
631        page_url: &str,
632        body: &str,
633        _opts: RequestOptions,
634    ) -> Result<Response> {
635        let challenge_data = CloudflareV2::extract_challenge_data(body)?;
636        let action = CloudflareV2::extract_form_action(body)?;
637        let submit_url = CloudflareV2::resolve_url(page_url, &action)?;
638
639        let delay = self
640            .config
641            .delay
642            .unwrap_or_else(|| rand::random::<f64>() * 4.0 + 1.0);
643        tokio::time::sleep(Duration::from_secs_f64(delay)).await;
644
645        let payload = CloudflareV2::build_js_payload(body, &challenge_data)?;
646
647        let parsed = Url::parse(page_url)?;
648        let origin = format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap_or(""));
649        let mut headers = HeaderMap::new();
650        headers.insert(ORIGIN, HeaderValue::from_str(&origin).unwrap());
651        headers.insert(REFERER, HeaderValue::from_str(page_url).unwrap());
652
653        let post_opts = RequestOptions {
654            form: Some(payload),
655            headers: Some(headers),
656            follow_redirects: Some(true),
657            ..Default::default()
658        };
659
660        Box::pin(self.request(Method::POST, &submit_url, post_opts)).await
661    }
662
663    async fn handle_v2_captcha(
664        &mut self,
665        page_url: &str,
666        body: &str,
667        _opts: RequestOptions,
668    ) -> Result<Response> {
669        let captcha_cfg = self.config.captcha.as_ref().ok_or_else(|| {
670            GhostwireError::CaptchaProviderMissing("No captcha provider configured".into())
671        })?;
672
673        let solver = make_solver(captcha_cfg).ok_or_else(|| {
674            GhostwireError::CaptchaProviderMissing(format!(
675                "Unknown captcha provider: {}",
676                captcha_cfg.provider
677            ))
678        })?;
679
680        let site_key = CloudflareV2::extract_site_key(body)?;
681        let token = solver
682            .solve(CaptchaKind::HCaptcha, page_url, &site_key, captcha_cfg)
683            .await?;
684
685        let challenge_data = CloudflareV2::extract_challenge_data(body)?;
686        let action = CloudflareV2::extract_form_action(body)?;
687        let submit_url = CloudflareV2::resolve_url(page_url, &action)?;
688        let payload = CloudflareV2::build_captcha_payload(body, &challenge_data, &token)?;
689
690        let parsed = Url::parse(page_url)?;
691        let origin = format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap_or(""));
692        let mut headers = HeaderMap::new();
693        headers.insert(ORIGIN, HeaderValue::from_str(&origin).unwrap());
694        headers.insert(REFERER, HeaderValue::from_str(page_url).unwrap());
695
696        let post_opts = RequestOptions {
697            form: Some(payload),
698            headers: Some(headers),
699            follow_redirects: Some(true),
700            ..Default::default()
701        };
702
703        Box::pin(self.request(Method::POST, &submit_url, post_opts)).await
704    }
705
706    async fn handle_v3(
707        &mut self,
708        page_url: &str,
709        body: &str,
710        _opts: RequestOptions,
711    ) -> Result<Response> {
712        let challenge_data = CloudflareV3::extract_challenge_data(body);
713
714        let action = challenge_data.form_action.as_deref().ok_or_else(|| {
715            GhostwireError::V3Error("Cannot find v3 challenge form action".into())
716        })?;
717        let submit_url = CloudflareV3::resolve_url(page_url, action)?;
718
719        let delay = self
720            .config
721            .delay
722            .unwrap_or_else(|| rand::random::<f64>() * 4.0 + 1.0);
723        tokio::time::sleep(Duration::from_secs_f64(delay)).await;
724
725        let domain = Url::parse(page_url)
726            .ok()
727            .and_then(|u| u.host_str().map(|h| h.to_string()))
728            .unwrap_or_default();
729
730        let answer =
731            CloudflareV3::execute_vm_challenge(&challenge_data, &domain, &self.js_interpreter);
732        let payload = CloudflareV3::build_payload(body, &answer)?;
733
734        let parsed = Url::parse(page_url)?;
735        let origin = format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap_or(""));
736        let mut headers = HeaderMap::new();
737        headers.insert(ORIGIN, HeaderValue::from_str(&origin).unwrap());
738        headers.insert(REFERER, HeaderValue::from_str(page_url).unwrap());
739
740        let post_opts = RequestOptions {
741            form: Some(payload),
742            headers: Some(headers),
743            follow_redirects: Some(true),
744            ..Default::default()
745        };
746
747        Box::pin(self.request(Method::POST, &submit_url, post_opts)).await
748    }
749
750    async fn handle_turnstile(
751        &mut self,
752        page_url: &str,
753        body: &str,
754        _opts: RequestOptions,
755    ) -> Result<Response> {
756        let captcha_cfg = self.config.captcha.as_ref().ok_or_else(|| {
757            GhostwireError::CaptchaProviderMissing(
758                "Turnstile detected but no captcha provider configured.".into(),
759            )
760        })?;
761
762        let solver = make_solver(captcha_cfg).ok_or_else(|| {
763            GhostwireError::CaptchaProviderMissing(format!(
764                "Unknown captcha provider: {}",
765                captcha_cfg.provider
766            ))
767        })?;
768
769        let site_key = CloudflareTurnstile::extract_site_key(body)?;
770        let token = solver
771            .solve(CaptchaKind::Turnstile, page_url, &site_key, captcha_cfg)
772            .await?;
773
774        let submit_url = CloudflareTurnstile::extract_form_action(body, page_url)?;
775        let payload = CloudflareTurnstile::build_payload(body, &token);
776
777        let delay = self
778            .config
779            .delay
780            .unwrap_or_else(|| rand::random::<f64>() * 4.0 + 1.0);
781        tokio::time::sleep(Duration::from_secs_f64(delay)).await;
782
783        let parsed = Url::parse(page_url)?;
784        let origin = format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap_or(""));
785        let mut headers = HeaderMap::new();
786        headers.insert(ORIGIN, HeaderValue::from_str(&origin).unwrap());
787        headers.insert(REFERER, HeaderValue::from_str(page_url).unwrap());
788
789        let post_opts = RequestOptions {
790            form: Some(payload),
791            headers: Some(headers),
792            follow_redirects: Some(true),
793            ..Default::default()
794        };
795
796        Box::pin(self.request(Method::POST, &submit_url, post_opts)).await
797    }
798}
799
800// ── RequestOptions ────────────────────────────────────────────────────────────
801
802/// Per-request options passed to `Ghostwire::request`.
803#[derive(Default)]
804pub struct RequestOptions {
805    /// Extra headers merged on top of defaults.
806    pub headers: Option<HeaderMap>,
807    /// URL-encoded form body. Takes precedence over `body_bytes`.
808    pub form: Option<Vec<(String, String)>>,
809    /// Raw byte body.
810    pub body_bytes: Option<Bytes>,
811    /// Per-request timeout.
812    pub timeout: Option<Duration>,
813    /// `Some(false)` = do NOT follow redirects; `None`/`Some(true)` = follow.
814    pub follow_redirects: Option<bool>,
815}
816
817// ── Helpers ───────────────────────────────────────────────────────────────────
818
819/// Reconstruct a `reqwest::Response` from raw status, headers and body text.
820///
821/// `reqwest::Response` can be constructed from an `http::Response<Bytes>` via
822/// its `From` implementation, which is the approach we use here.
823fn build_text_response(status: u16, headers: HeaderMap, body: String) -> Result<Response> {
824    let body_bytes = Bytes::from(body.into_bytes());
825    let mut builder = http::Response::builder().status(status);
826    for (k, v) in &headers {
827        builder = builder.header(k, v);
828    }
829    let http_resp = builder
830        .body(body_bytes)
831        .map_err(|e| GhostwireError::Other(e.to_string()))?;
832    Ok(reqwest::Response::from(http_resp))
833}