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