1use 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#[derive(Debug, Clone)]
28pub struct GhostwireBuilder {
29 pub disable_v1: bool,
31 pub disable_v2: bool,
32 pub disable_v3: bool,
33 pub disable_turnstile: bool,
34
35 pub delay: Option<f64>,
37
38 pub solve_depth: usize,
40
41 pub captcha: Option<CaptchaConfig>,
43
44 pub double_down: bool,
46
47 pub stealth: StealthConfig,
49
50 pub user_agent_opts: UserAgentOptions,
52
53 pub proxies: Vec<String>,
55 pub proxy_rotation: RotationStrategy,
56 pub proxy_ban_secs: u64,
57
58 pub session_refresh_secs: u64,
60 pub auto_refresh_on_403: bool,
61 pub max_403_retries: usize,
62
63 pub min_request_interval_secs: f64,
65
66 pub debug: bool,
68
69 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 pub fn js_interpreter(mut self, interp: JsInterpreter) -> Self {
201 self.js_interpreter = interp;
202 self
203 }
204
205 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
245pub 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 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 pub fn new() -> Result<Self> {
270 GhostwireBuilder::new().build()
271 }
272
273 pub fn builder() -> GhostwireBuilder {
275 GhostwireBuilder::new()
276 }
277
278 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 #[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 self.throttle().await;
325
326 {
328 let mut stealth = self.stealth.lock().await;
329 stealth.pre_request().await;
330 }
331
332 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 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 let body = response.text().await.map_err(GhostwireError::HttpError)?;
356
357 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 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 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 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 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 self.solve_depth = 0;
427 self.retry_403_count = 0;
428 build_text_response(status, headers_clone, body)
429 }
430
431 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 if let Some(h) = &opts.headers {
443 req = req.headers(h.clone());
444 }
445
446 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 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 if let Some(t) = opts.timeout {
464 req = req.timeout(t);
465 }
466
467 if opts.follow_redirects == Some(false) {
469 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 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 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 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 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 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#[derive(Default)]
804pub struct RequestOptions {
805 pub headers: Option<HeaderMap>,
807 pub form: Option<Vec<(String, String)>>,
809 pub body_bytes: Option<Bytes>,
811 pub timeout: Option<Duration>,
813 pub follow_redirects: Option<bool>,
815}
816
817fn 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}