Skip to main content

http_quik/client/
pool.rs

1use std::collections::HashMap;
2use std::net::ToSocketAddrs;
3use std::sync::{Arc, Mutex};
4use url::Url;
5
6use crate::client::connector::{connect, QuikConnection};
7use crate::client::proxy::Proxy;
8use crate::client::request::{inject_chrome_headers, RequestContext};
9use crate::client::response::Response;
10use crate::error::{Error, Result};
11use crate::profile::ChromeProfile;
12
13use bytes::Bytes;
14use cookie_store::CookieStore;
15use std::sync::RwLock;
16
17/// A stateful, pooling HTTP client that enforces Chrome transport identity.
18///
19/// The `Client` is the primary entry point for the `http-quik` library. It manages:
20/// 1. **Connection Pooling**: Reuses established HTTP/2 sessions to maintain persistent fingerprints.
21/// 2. **Cookie Persistence**: A synchronized cookie jar shared across all requests.
22/// 3. **Stealth Redirects**: Automatically follows redirects while mutating headers and methods
23///    to match Chromium's behavioral markers.
24/// 4. **OS Auto-Detection**: Defaults to a Chrome profile matched to the host OS,
25///    ensuring consistency between the TLS/H2 persona and the kernel's TCP stack.
26///
27/// # Example
28/// ```rust
29/// use http_quik::Client;
30///
31/// let client = Client::new();
32/// ```
33type SharedConnection = Arc<tokio::sync::Mutex<Option<QuikConnection>>>;
34type ConnectionPool = Arc<Mutex<HashMap<String, SharedConnection>>>;
35
36#[derive(Clone)]
37pub struct Client {
38    /// A synchronized pool of active H2 connections keyed by their origin and proxy.
39    pool: ConnectionPool,
40    /// The canonical identity profile used for all transport-layer operations.
41    profile: ChromeProfile,
42    /// An optional proxy used for all outbound connections.
43    proxy: Option<Proxy>,
44    /// A synchronized cookie jar shared across all requests.
45    ///
46    /// This store is thread-safe and is automatically updated during redirect
47    /// chains and standard request execution.
48    pub cookie_store: Arc<RwLock<CookieStore>>,
49}
50
51impl Default for Client {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl Client {
58    /// Creates a new `Client` with a Chrome 134 profile auto-matched to the host OS.
59    ///
60    /// The profile is selected at compile time to ensure consistency between
61    /// the TLS/H2 persona and the host kernel's TCP stack.
62    /// For custom profiles or proxies, use [`Client::builder`].
63    pub fn new() -> Self {
64        Self::builder().build().unwrap_or_else(|_| Client {
65            pool: Arc::new(Mutex::new(HashMap::new())),
66            profile: crate::profile::chrome_134::profile_auto(),
67            proxy: None,
68            cookie_store: Arc::new(RwLock::new(CookieStore::default())),
69        })
70    }
71
72    /// Returns a [`ClientBuilder`] to configure a specialized `Client` instance.
73    pub fn builder() -> ClientBuilder {
74        ClientBuilder::default()
75    }
76
77    /// Executes a GET request and follows redirects stealthily.
78    pub async fn get(&self, url: &str) -> Result<Response> {
79        self.execute_with_redirects("GET", url, None, RequestContext::Navigate).await
80    }
81
82    /// Executes a POST request and follows redirects stealthily.
83    pub async fn post(&self, url: &str, body: Bytes) -> Result<Response> {
84        self.execute_with_redirects("POST", url, Some(body), RequestContext::Navigate).await
85    }
86
87    /// Core request execution engine with automated, stateful redirect handling.
88    ///
89    /// This method implements a high-fidelity Chromium redirect state machine:
90    ///
91    /// 1. **Sec-Fetch-Site Evolution**: Dynamically calculates origin relationships
92    ///    (same-origin, same-site, cross-site) across hops to maintain stealth.
93    /// 2. **Header Mutation**: Automatically strips `sec-fetch-user` and
94    ///    `upgrade-insecure-requests` after the first hop, exactly like Chrome.
95    /// 3. **Method Rotation**: Rotates POST requests to GET for 301, 302, and 303
96    ///    status codes to prevent out-of-spec behavioral markers.
97    /// 4. **H2 Multiplexing**: Reuses existing connections from the pool to avoid
98    ///    redundant TLS handshakes that could trigger anti-bot alerts.
99    async fn execute_with_redirects(
100        &self,
101        initial_method: &str,
102        initial_url: &str,
103        initial_body: Option<Bytes>,
104        context: RequestContext,
105    ) -> Result<Response> {
106        let mut current_url_str = initial_url.to_string();
107        let mut current_method = initial_method.to_string();
108        let mut current_body = initial_body;
109        let mut previous_url_str: Option<String> = None;
110
111        let mut sec_fetch_site = "none".to_string();
112        let mut is_cross_site = false;
113
114        for hop in 0..10 {
115            let parsed_url =
116                Url::parse(&current_url_str).map_err(|e| Error::InvalidUrl(e.to_string()))?;
117            let authority = parsed_url
118                .host_str()
119                .ok_or_else(|| Error::InvalidUrl("missing host".to_string()))?;
120            let port = parsed_url.port().unwrap_or_else(|| {
121                if parsed_url.scheme() == "http" {
122                    80
123                } else {
124                    443
125                }
126            });
127
128            // Build a unique pool key considering the proxy and target origin.
129            let proxy_prefix = self
130                .proxy
131                .as_ref()
132                .map(|p| match p {
133                    Proxy::Http(a) => format!("http://{}@", a),
134                    Proxy::Socks5(a) => format!("socks5://{}@", a),
135                })
136                .unwrap_or_default();
137
138            let key = format!("{}{}:{}", proxy_prefix, authority, port);
139
140            // Extract relevant cookies for the current target URL.
141            let cookie_header = {
142                let store = self
143                    .cookie_store
144                    .read()
145                    .map_err(|_| Error::Connect(std::io::Error::other("cookie store poisoned")))?;
146                let cookies: Vec<_> = store
147                    .matches(&parsed_url)
148                    .iter()
149                    .map(|c| format!("{}={}", c.name(), c.value()))
150                    .collect();
151                if cookies.is_empty() {
152                    None
153                } else {
154                    Some(cookies.join("; "))
155                }
156            };
157
158            let mut request = http::Request::builder()
159                .method(current_method.as_str())
160                .uri(parsed_url.as_str())
161                .body(())
162                .map_err(|e| Error::InvalidUrl(e.to_string()))?;
163
164            if let Some(c) = cookie_header.as_deref() {
165                if let Ok(val) = http::header::HeaderValue::from_str(c) {
166                    request.headers_mut().insert("cookie", val);
167                }
168            }
169
170            // Inject Origin header for mutation methods (POST, PUT, PATCH)
171            // Chrome sends this even for same-origin requests to prevent CSRF.
172            if current_method == "POST" || current_method == "PUT" || current_method == "PATCH" {
173                if let Ok(val) =
174                    http::header::HeaderValue::from_str(&parsed_url.origin().ascii_serialization())
175                {
176                    request.headers_mut().insert("origin", val);
177                }
178            }
179
180            // Injects Chrome-identical headers, handling dynamic Sec-Fetch and Priority states.
181            let is_initial = hop == 0;
182            // TODO: Extract Accept-CH correctly from cookie store or session if needed. We default to false for initial hop to avoid leaking platform details unnecessarily.
183            let accept_ch = false; 
184
185            inject_chrome_headers(
186                request.headers_mut(),
187                &self.profile,
188                &sec_fetch_site,
189                is_initial,
190                context,
191                accept_ch,
192                previous_url_str.as_deref(),
193            );
194
195            // Connection acquisition logic: use an async Mutex per origin to avoid race conditions
196            // where concurrent requests dial redundant TLS connections to the same host.
197            let conn_mutex = {
198                let mut pool = self.pool.lock().map_err(|_| {
199                    Error::Connect(std::io::Error::other("connection pool poisoned"))
200                })?;
201                pool.entry(key.clone())
202                    .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(None)))
203                    .clone()
204            };
205
206            let mut h2_client = {
207                let mut guard = conn_mutex.lock().await;
208                
209                let need_dial = match &mut *guard {
210                    Some(c) => {
211                        // Verify if the pooled connection is still active and ready for a new stream.
212                        match c.h2.clone().ready().await {
213                            Ok(h2) => {
214                                c.h2 = h2;
215                                false
216                            }
217                            Err(_) => true,
218                        }
219                    }
220                    None => true,
221                };
222
223                if need_dial {
224                    let new_conn = self.dial(authority, port, &self.profile).await?;
225                    *guard = Some(new_conn);
226                }
227
228                guard.as_ref().unwrap().clone()
229            };
230
231            let mut response = h2_client.send(request, current_body.clone()).await?;
232
233            self.store_cookies(&response, &parsed_url);
234
235            let status = response.status();
236            if status.is_redirection() {
237                if let Some(location) = response.headers().get("location") {
238                    let loc_str = location.to_str().unwrap_or("");
239                    let next_url = parsed_url
240                        .join(loc_str)
241                        .map_err(|e| Error::InvalidUrl(e.to_string()))?;
242
243                    // Redirect Mutation: Rotate POST to GET on standard redirects.
244                    if status == http::StatusCode::MOVED_PERMANENTLY
245                        || status == http::StatusCode::FOUND
246                        || status == http::StatusCode::SEE_OTHER
247                    {
248                        current_method = "GET".to_string();
249                        current_body = None;
250                    }
251
252                    // sec-fetch-site computation: Once cross-site, always cross-site.
253                    if !is_cross_site {
254                        if next_url.origin() == parsed_url.origin() {
255                            sec_fetch_site = "same-origin".to_string();
256                        } else if next_url.domain() == parsed_url.domain() {
257                            sec_fetch_site = "same-site".to_string();
258                        } else {
259                            sec_fetch_site = "cross-site".to_string();
260                            is_cross_site = true;
261                        }
262                    }
263
264                    previous_url_str = Some(current_url_str);
265                    current_url_str = next_url.to_string();
266                    continue;
267                }
268            }
269
270            response.set_url(current_url_str);
271            return Ok(response);
272        }
273
274        Err(Error::Connect(std::io::Error::other(
275            "Redirect limit exceeded (max 10)",
276        )))
277    }
278
279    /// Dials a new connection following the profile's transport constraints.
280    async fn dial(
281        &self,
282        authority: &str,
283        port: u16,
284        profile: &ChromeProfile,
285    ) -> Result<QuikConnection> {
286        let addr_str = format!("{}:{}", authority, port);
287        let addr = addr_str.to_socket_addrs()?.next().ok_or_else(|| {
288            std::io::Error::new(std::io::ErrorKind::NotFound, "could not resolve host")
289        })?;
290
291        connect(authority, port, addr, profile, self.proxy.as_ref()).await
292    }
293
294    /// Persists `Set-Cookie` headers from a response into the synchronized cookie store.
295    fn store_cookies(&self, resp: &Response, url: &Url) {
296        if let Ok(mut store) = self.cookie_store.write() {
297            for v in resp.headers().get_all("set-cookie").iter() {
298                if let Ok(cookie_str) = v.to_str() {
299                    let _ = store.parse(cookie_str, url);
300                }
301            }
302        }
303    }
304}
305
306/// A builder for constructing a `Client` with specific identity and transport settings.
307#[derive(Default)]
308pub struct ClientBuilder {
309    profile: Option<ChromeProfile>,
310    proxy: Option<Proxy>,
311    cookie_store: Option<Arc<RwLock<CookieStore>>>,
312    danger_accept_invalid_certs: bool,
313}
314
315impl ClientBuilder {
316    /// Disables certificate verification.
317    ///
318    /// # Warning
319    /// Using this makes the client vulnerable to Man-in-the-Middle (MitM) attacks.
320    /// Only use this for testing or local proxy interception.
321    pub fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
322        self.danger_accept_invalid_certs = accept;
323        self
324    }
325
326    /// Sets the Chrome identity profile.
327    pub fn profile(mut self, profile: ChromeProfile) -> Self {
328        self.profile = Some(profile);
329        self
330    }
331
332    /// Configures an outbound proxy.
333    pub fn proxy(mut self, proxy: Proxy) -> Self {
334        self.proxy = Some(proxy);
335        self
336    }
337
338    /// Provides a pre-existing synchronized cookie store.
339    pub fn cookie_store(mut self, store: Arc<RwLock<CookieStore>>) -> Self {
340        self.cookie_store = Some(store);
341        self
342    }
343
344    /// Finalizes the configuration and constructs a `Client`.
345    pub fn build(self) -> Result<Client> {
346        let mut profile = self
347            .profile
348            .unwrap_or_else(crate::profile::chrome_134::profile_auto);
349
350        if self.danger_accept_invalid_certs {
351            profile.tls.verify_peer = false;
352        }
353
354        Ok(Client {
355            pool: Arc::new(Mutex::new(HashMap::new())),
356            profile,
357            proxy: self.proxy,
358            cookie_store: self
359                .cookie_store
360                .unwrap_or_else(|| Arc::new(RwLock::new(CookieStore::default()))),
361        })
362    }
363}