Rust 瀏覽器指紋實作:reqwest 與 wreq 的 TLS/HTTP/2 差異

把同一筆 API 請求改寫成 Rust,URL、Header 與 JSON 都相同,伺服器仍可能觀察到不同的 TLS 握手與 HTTP/2 連線參數。這些特徵主要來自 HTTP Client 的底層實作;修改 User-Agent 可以改變請求的自我描述,卻不會連帶換掉 TLS backend。

reqwest 適合建立一般 Rust HTTP Client 的基準;需要調整瀏覽器的傳輸特徵時,wreq 提供 Browser Emulation Profile。兩者都有 Builder、非同步請求與連線池,但 Profile、Cookie、Response ownership 和原生編譯依賴,會影響程式該如何組織。

Quick Start

固定使用 Rust 1.98.0、wreq 0.16.1 與 wreq-util 0.2.0,並以已安裝 rustup 為前提。Linux 的首次編譯會建置 BoringSSL;Ubuntu/Debian 可先安裝編譯工具:

sudo apt-get update
sudo apt-get install -y build-essential cmake perl pkg-config libclang-dev git
rustup toolchain install 1.98.0 --profile minimal
cargo +1.98.0 new fingerprint-quick-start
cd fingerprint-quick-start
cargo +1.98.0 add wreq@=0.16.1 --features json
cargo +1.98.0 add wreq-util@=0.2.0
cargo +1.98.0 add tokio@=1.53.1 --features macros,rt-multi-thread
cargo +1.98.0 add serde_json@=1.0.151

將 src/main.rs 換成以下程式,發送一筆套用 Chrome 124 Profile 的 GET:

use std::time::Duration;
use wreq::Client;
use wreq_util::Emulation;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder()
        .emulation(Emulation::Chrome124)
        .timeout(Duration::from_secs(15))
        .build()?;

    let response = client
        .get("https://tls.browserleaks.com/json")
        .send()
        .await?
        .error_for_status()?;
    println!("HTTP version: {:?}", response.version());
    let data: serde_json::Value = response.json().await?;
    println!("JA3N: {}", data["ja3n_hash"]);
    Ok(())
}
cargo +1.98.0 run --locked

這次實際執行的結果:

HTTP version: HTTP/2.0
JA3N: "4c9ce26028c11d7544da00d3f7e4f45c"

emulation 指定連線使用的 Profile,timeout 限制請求與回應 Body 的總時間。先取出 version(),再呼叫 json(),是因為讀取 JSON 會消耗 Response。若輸出 HTTP/2.0,表示這次實際協商使用 HTTP/2;JA3N 則是診斷端點根據 TLS 握手計算的摘要。

Quick Start 的 Emulation::Chrome124 使用預設 macOS 平台。需要固定作業系統相關 Header 時,可再透過 Profile Builder 選擇 Windows、macOS 或 Linux;這些設定不會改變程式真正執行的作業系統。

Client 架構與固定版本

reqwest、wreq 與 TLS backend

reqwest 0.13.4 預設使用 rustls,也能透過 feature 改用 native-tls。wreq 0.16.1 使用 btls 串接 BoringSSL,並搭配可調整的 HTTP 協定實作;Browser Profile 由 wreq-util 提供。reqwest featureswreq 發行套件 與 wreq-util 發行套件 是版本核對的起點。

Rust application/Tokio
    ├─ reqwest → HTTP stack → rustls → TCP
    └─ wreq + wreq-util Profile → 可調整的 HTTP stack → btls/BoringSSL → TCP

wreq 的 HTTP API 與非同步控制主要位於 Rust,TLS 仍涉及原生 BoringSSL。部署時可以交付編譯後的 binary,但建置環境仍需 C/C++ 工具鏈;不能把「Rust Client」解讀成完全沒有 native dependency。

舊名稱 rquest 的 GitHub 位址目前會轉向 wreqcrates.io 上的舊版本已被 yank。Yank 不會使現有 lockfile 立刻失效,但新的教學與依賴解析應採用目前的套件名稱。rquest repositoryrquest crate

另一條路徑是在 Rust 經 FFI 呼叫 libcurl-impersonate,或啟動對應的 curl subprocess。FFI 可以保留 libcurl 的 handle 與連線池;subprocess 則要自行處理程序生命週期、輸入輸出及錯誤轉換。既有系統若已維護 libcurl,這條路徑有整合價值;直接採用 wreq 則能讓請求與 Tokio、Rust 型別及錯誤處理留在同一個程序內。

Cargo、Feature Flags 與建置環境

2026-09-08 查核時,wreq 0.16.1 與 wreq-util 0.2.0 均為非預發布版本,最低 Rust 版本都是 1.98。網路上仍有 wreq 6.0.0-rc.*、舊版 utility crate 與不同 API 的範例;版本號較大的舊系列不能直接與現在的 API 混用。

完整配套程式位於 GitHub repository。要執行四組診斷與後續範例,可另開終端機下載專案;配套的 Cargo 與 Docker 指令均從 rust-wreq/ 目錄執行:

git clone https://github.com/hsunAlfred/http-client-fingerprint-examples.git
cd http-client-fingerprint-examples/rust-wreq

若已下載過同一 repository,直接進入其中的 rust-wreq/ 即可。配套 Cargo.toml 包含診斷 CLI 與後續範例需要的完整依賴:

[package]
name = "fingerprint-client-rs"
version = "0.1.0"
edition = "2024"
rust-version = "1.98"
publish = false

[dependencies]
clap = { version = "=4.6.6", features = ["derive"] }
futures-util = { version = "=0.3.34", features = ["sink"] }
reqwest = { version = "=0.13.4", default-features = false, features = ["rustls", "http2", "json", "query", "form", "stream"] }
serde = { version = "=1.0.229", features = ["derive"] }
serde_json = "=1.0.151"
tempfile = "=3.27.0"
thiserror = "=2.0.20"
tokio = { version = "=1.53.1", features = ["macros", "rt-multi-thread", "fs", "io-util", "net", "time", "sync"] }
tokio-util = { version = "=0.7.18", features = ["io"] }
wreq = { version = "=0.16.1", features = ["json", "query", "form", "cookies", "multipart", "stream", "ws", "socks", "gzip", "brotli", "deflate", "zstd"] }
wreq-util = { version = "=0.2.0", features = ["emulation-compression"] }

[dev-dependencies]
tokio-tungstenite = "=0.29.0"

=0.16.1 代表精確版本限制;實際使用的全部間接依賴另由 Cargo.lock 固定。配套也提供 rust-toolchain.toml,使專案目錄中的 Cargo 指令使用 Rust 1.98.0。

實測環境 版本
主機/容器 Ubuntu 22.04.5/Debian 12 Bookworm,x86_64
rustc/cargo 1.98.0/1.98.0
CMake/libclang 3.25.1/14
wreq TLS binding btls 0.5.6、btls-sys 0.5.6
reqwest TLS backend rustls 0.23.44,AWS-LC provider
Tokio 1.53.1

這份 lockfile 也包含 wreq-rt 0.2.2-rc.4:頂層 wreq 是非預發布版本,不代表整條間接依賴都沒有 RC。升級時需要一起檢查 Profile、runtime 與底層 TLS/HTTP crate 的變化。

Feature 用途
wreq 預設 webpki-roots,tokio-rt Mozilla 信任根與 Tokio 執行環境整合
json,query,form 對應 JSON、Query String 與表單編碼 API
cookies Cookie Jar 與自動接收/附加 Cookie
stream,multipart 串流 Body 與 Multipart 上傳
ws,socks WebSocket 與 SOCKS Proxy
gzip,brotli,deflate,zstd 回應 Body 自動解壓縮
wreq-util emulation-compression Profile 中的壓縮相關 Header
reqwest rustls,http2 明確選定對照組的 TLS backend 與 HTTP/2 支援

Feature Flags 是編譯時能力開關。啟用 Profile 的壓縮宣告時,也應提供相應解碼能力;只有 Header 宣告、沒有解碼能力,可能得到應用程式無法處理的壓縮 Body。這份 Cargo 設定已將兩者配對。

reqwest 明確關閉 default features,再列出所需能力,便於核對對照組。這也避免把 0.12 的 rustls-tls feature 名稱搬到 0.13;目前使用的是 rustls,而 query 與 form 也各有 feature。reqwest 0.13.4 Cargo.toml

若其他 crate 同時引入 openssl-sys,應檢查它與 BoringSSL 的符號衝突。wreq 的 prefix-symbols 可用於 Linux/Android 的對應建置,不能直接推定所有平台都有相同行為。官方建置說明

reqwest 與 wreq 的指紋實驗

四組 Client 與控制條件

同一個公開診斷端點,可以先比較以下四組 Client:

組別 TLS/HTTP 實作 Header/Profile 設定
reqwest rustls、reqwest HTTP stack 一般 Client
reqwest-ua 與 reqwest 相同 只加入 Chrome 124 Windows User-Agent
wreq BoringSSL、wreq HTTP stack 不指定 Browser Profile
wreq-chrome BoringSSL、wreq HTTP stack Chrome 124+Windows Profile

四組都使用相同 URL、相同主機網路出口、不自動使用 Proxy、不追蹤 Redirect,也不保存 Cookie。CLI 先以同一個 URL parser 解析輸入,將正規化後的字串交給兩套 Client,讓 dot segments、反斜線與 Unicode 路徑不會因 parser 不同而改變請求目標。每組以新的 Client 建立首筆連線,避免把不同的暖連線狀態混進初次握手比較。TLS、JA3/JA4 與 HTTP/2 欄位的判讀方式可對照 HTTP Client 與瀏覽器指紋

從配套目錄執行四次單筆診斷,每次建立獨立 Client:

cargo run --locked -- --url https://tls.browserleaks.com/json --client reqwest
cargo run --locked -- --url https://tls.browserleaks.com/json --client reqwest-ua
cargo run --locked -- --url https://tls.browserleaks.com/json --client wreq
cargo run --locked -- --url https://tls.browserleaks.com/json --client wreq-chrome --profile chrome124

2026-09-08 在 Debian 12 容器、Ubuntu 主機的同一網路出口實測,四組都收到 HTTP 200,結果如下。原始白名單結果保存在 observations.json;這些 Hash 是當次觀察值,不是測試必須永遠符合的常數。

Client HTTP JA3N Akamai HTTP/2 Hash
reqwest HTTP/2 bb37f13a85d080ea42c708e55800985d 9b5dcd077a77c5e324b91d8cc306fd5a
reqwest-ua HTTP/2 bb37f13a85d080ea42c708e55800985d 9b5dcd077a77c5e324b91d8cc306fd5a
wreq HTTP/2 620628e68f7a3fc36de7125c3112aec7 787b78994836bff666aa7c3258e52189
wreq-chrome HTTP/2 4c9ce26028c11d7544da00d3f7e4f45c 52d84b11737d980aef856699f885ca86

reqwest 與 reqwest-ua 的 JA4 都是 t13d1011h2_61a7ad8aa9b6_0d308c48d2a3,普通 wreq 是 t13d2811h2_257f3020b3a2_78e6aca7449b,Chrome Profile 組則是 t13d1516h2_8daaf6152771_02713d6af862

這次兩組 reqwest 的原始 JA3 不同,但 JA3N、JA4 與 HTTP/2 摘要一致;不能只看原始 JA3 的差異,就把它歸因於 User-Agent。Chrome Profile 組的 JA3N、JA4 與 Akamai Hash 也和 Python 篇的 curl_cffi chrome124 實測相同,但摘要一致仍不代表整個 Client 實作或所有封包一致。

只修改 User-Agent 的結果,可以用來觀察應用層文字與 TLS 實作之間的界線;這是 reqwest 作為一般 HTTP Client 的設計定位。套用 Profile 後,仍應將 TLS 摘要、HTTP/2 摘要與實際 Header 一起判讀。

JA3 可能因 Extension Permutation 變動;JA3N/JA4 則省略或正規化部分資訊。Hash 相同只能表示相應摘要相同,不能證明所有封包欄位、真實瀏覽器版本或操作身分相同。JA3N 的正規化規則也依診斷服務而異。

Browser、Platform 與 Header 一致性

沿用實驗中的 Chrome 124+Windows Profile,可以建立供一般 HTTP 範例重用的 Client。以下 Builder 擷取自 http.rs,另外加入 Cookie Store、重導與 timeout;前面的四組診斷仍維持各自的實驗控制條件:

pub fn build_client() -> wreq::Result<Client> {
    Client::builder()
        .emulation(
            Emulation::builder()
                .profile(Profile::Chrome124)
                .platform(Platform::Windows)
                .build(),
        )
        .no_proxy()
        .cookie_store(true)
        .redirect(Policy::limited(3))
        .connect_timeout(Duration::from_secs(5))
        .read_timeout(Duration::from_secs(10))
        .timeout(Duration::from_secs(20))
        .build()
}

profile 選擇瀏覽器版本的 TLS/HTTP 設定,platform 主要影響 User-Agent、Client Hints 等平台相關 Header;它不會把 Linux kernel 的 TCP 行為改成 Windows。http2 與 headers 則可控制是否帶入 Profile 的對應部分。wreq-util 0.2.0 Profile 設定

先套用 emulation,再做必要的細部調整。Profile 會帶入 TLS、HTTP/1、HTTP/2 與 Header 設定;後續若覆寫 User-Agent 或 Client Hints,就要重新檢查彼此是否一致。Profile 的預設 Header 也不會自動理解目前是頁面導覽、fetch 或圖片請求,應用程式仍需依實際 API 語境設定 Header。

Client-level 與 Request-level 都能使用 emulation。Request-level 設定適合局部調整;需要隔離登入身分時,仍應以獨立 Client/Cookie Store 管理。連線分組不等於 Cookie 或業務狀態隔離。RequestBuilder source

HTTP/2 與 HTTP/3 的驗證邊界

http1_only() 與 http2_only() 可以限制 wreq 的協定選擇。一般協商則應檢查 response.version(),不要只依「啟用 HTTP/2 feature」推定實際使用的版本;ALPN、伺服器能力與連線設定都會參與結果。

HTTP/2 指紋可能包含 SETTINGS 值與順序、初始 WINDOW_UPDATE、Pseudo-header 順序及 Priority 行為。Browser Profile 的原始設定可以作為核對材料,診斷端點的 Akamai Hash 則是其中一種摘要;若要確認實際 frame,仍需觀察握手後的 HTTP/2 流量。

HTTPS 的 HTTP/2 frame 位於 TLS 加密內。Wireshark 分析需要在對應環境取得可用的 TLS secrets,或於自有 TLS 終端觀察解密後流量。一般 tcpdump pcap 不能單憑 http2.type == 4 就讀出加密連線的 SETTINGS。這次驗證使用診斷端點與本機功能測試,沒有把它描述成真實 Chrome 的完整 pcap 對照。

wreq 0.16.1 尚未提供可用的 QUIC/HTTP/3 Client;即使型別中有 HTTP_3 或 HTTP/3 ALPN 常數,也不代表底層能完成 HTTP/3 傳輸。固定版的送出路徑只接受 HTTP/1.0、HTTP/1.1 與 HTTP/2。協定版本檢查 source

reqwest 0.13.4 的 HTTP/3 仍屬不穩定功能,需要 http3 feature 與 reqwest_unstable 編譯設定。這與重現 Chrome 的 QUIC transport parameters 是不同能力;這裡的四組對照固定在 TLS/HTTP/2 範圍。reqwest HTTP/3 feature

HTTP、Cookie 與 Client 生命週期

Request Body 與 Typed Response

Rust 的 Request Builder 負責累積 URL、Headers、Body 與網路選項;.send().await 才會真正送出請求。JSON 可直接由 serde 型別編碼,Response 也可以反序列化成明確的 struct,讓欄位缺失與型別錯誤在解碼階段被辨識。

http.rs 定義的 Item 包含名稱 name 與數量 count,並用同一個型別處理送出與回傳的 JSON:

#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct Item {
    pub name: String,
    pub count: u32,
}

以下函式使用前面的 Client;完整 imports 與執行入口位於同一檔案:

pub async fn create_item(client: &Client, url: &str, item: &Item) -> wreq::Result<Item> {
    let response = client
        .post(url)
        .query(&[("source", "article"), ("view", "compact")])
        .header("x-example", "rust-wreq")
        .json(item)
        .send()
        .await?
        .error_for_status()?;

    // status()、headers() 只借用 response;json() 消費 body 的 ownership。
    println!("status: {}", response.status());
    println!("content-type: {:?}", response.headers().get("content-type"));
    response.json::<Item>().await
}

pub async fn submit_form(client: &Client, url: &str) -> wreq::Result<String> {
    client
        .post(url)
        .form(&[("title", "Rust HTTP"), ("lang", "zh-TW")])
        .send()
        .await?
        .error_for_status()?
        .text()
        .await
}

query 負責 Query String 編碼,json 設定 JSON Body 與 Content-Type,form 則編碼表單。body 可接收原始位元組;PUT、PATCH、DELETE 也使用相同的 Request Builder 模型。Authentication 可由 basic_auth 或 bearer_auth 設定,值應由受控設定來源提供,避免寫進範例或 log。

HTTP 4xx/5xx 仍是成功收到的 HTTP Response;.send().await? 不會單憑狀態碼把它當成傳輸錯誤。error_for_status() 才會將這類回應轉成錯誤。需要保存錯誤狀態碼或應用層錯誤 Body 時,應先讀取必要 metadata,再依自有 API 契約處理。

bytes()text()json() 與 bytes_stream() 都消耗 Response。若同時需要 status、headers 與 Body,先複製少量必要 metadata,再選一種 Body 讀取方式;不能讀完 JSON 後再讀一次原始 Body。wreq 0.16 使用 .uri() 取得最終 URI,和 reqwest 的 .url() 也不同。Response API source

content_length() 可能回傳 None。自動解壓縮後的 Body 大小也不一定等於線上 Content-Length,因此下載上限要根據實際讀到的 bytes 累計。

Redirect 與 Timeout

固定版本的 wreq Client 預設不追蹤 Redirect;如果應用需要跟隨,明確設定 redirect::Policy::limited(5)Policy::default() 本身則代表最多十次,和 Client 的預設初始化並不相同。使用明確 Policy 可以避免被舊版註解或不同 library 的預設行為誤導。Client 初始化 source

設定 控制範圍
connect_timeout DNS、TCP 連線嘗試、Proxy tunnel 與 TLS handshake
timeout 請求進入傳輸流程至 Response Body 讀取完成的總時間
read_timeout 限制取得完整 Response Headers 前的等待;Body 階段每次成功取得 frame 後重新計時
Tokio timeouttimeout_at 包住整個工作 Future,可涵蓋排隊、重試與非同步檔案寫入

wreq 0.16.1 的 read_timeout 分成兩個階段:請求進入傳輸流程起、完整 Headers 返回前,維持同一個計時器,因此連線與上傳期間的等待也可能計入;Body 則在首次 poll 時啟動新的計時,之後每次成功取得 frame 都重新計時。這不是單純的 socket read idle timeout,應用程式處理 chunk 太久,也可能耗盡下一次 Body 讀取的預算。總 timeout 則維持同一個 deadline,跨越內部 Redirect/Retry 與 Body。Timeout middlewareResponse FutureBody timer

Client 預設沒有這些請求 timeout,實務上應明確設定。Request Builder 的 timeout 與 read_timeout 可覆寫單筆設定;應用程式自行重建請求、多次 Retry 時,要共享另一個總 deadline,否則每次重新取得完整 timeout,整體等待可能遠超預期。

Redirect 同時會影響 Method、Body 重送與認證邊界。Streaming Body 通常不能複製;307/308 要保留 Body 時,不能假設任意 stream 都能重播。跨來源認證資料也應依實際 library 與自有策略驗證。

Cookie Store、連線池與身分邊界

啟用 cookies feature 後,.cookie_store(true) 會建立 Cookie Store,接收 Set-Cookie 並在後續符合條件的請求帶入。若需要由多個 Client 明確共用同一份 Jar,可使用 .cookie_provider(Arc<Jar>);這個選擇代表 Cookie 狀態確實被共用。

Domain、Path、Secure 等屬性會影響 Cookie 是否附加。這是 HTTP 層的 Cookie 管理,並不包含頁面的 JavaScript、DOM、localStorage 或完整瀏覽器環境。Cookie Jar source

Client 可以 clone 後分給不同 Future,clone 共用內部連線資源,不必再包一層 Mutex 把所有請求序列化。Client 應跨請求重用,讓 Keep-Alive、TLS session 與 HTTP/2 multiplexing 有機會發揮作用。不同登入身分、Cookie、Proxy 與 Profile 的組合,則應明確劃分 Client 的使用範圍。

完整讀完 Response Body 有助於重用 HTTP/1.1 連線。提前 drop Body 時,library 可能取消 stream 或放棄連線,不能把「取得 response headers」當成傳輸與資源回收已全部完成。HTTP/2 的多 stream 共用一條 TCP 連線,也使請求數、連線數與 stream 數不能直接畫上等號。

Proxy、CA 與網路設定

Proxy 路由與 DNS

Proxy::all 可以設定 HTTP 或 SOCKS Proxy;socks5:// 在本機解析目標名稱,socks5h:// 則把名稱交給代理。代理 URI 中的帳密、Proxy-Authorization 與完整連線錯誤都不適合直接輸出至共用 log。SOCKS connector source

以下函式擷取自 network.rsproxy_url 是代理 URI,例如本機測試用的 socks5h://127.0.0.1:1080

pub fn proxy_client(proxy_url: &str) -> wreq::Result<Client> {
    Client::builder()
        .emulation(Emulation::Chrome124)
        .no_proxy()
        .proxy(Proxy::all(proxy_url)?)
        .timeout(Duration::from_secs(20))
        .build()
}

.no_proxy() 會清除 Client 的代理設定並停用自動代理來源。四組指紋實驗採用明確直連,讓代理環境變數不會悄悄改變傳輸路徑。實際部署若需要代理,應明確建立對應 Client,並把代理設定納入實驗紀錄。

Request-level 也可指定 Proxy,但頻繁切換 Proxy 會改變連線重用條件。CONNECT tunnel 通常保留到目標端的 TLS 握手;TLS inspection proxy 則終止並重新建立 TLS,目標端觀察到的可能是代理的握手。

CA 與 mTLS

wreq 0.16.1 使用 tls::trust::CertStore 與 tls::trust::Identity,不能直接搬用 reqwest 的 add_root_certificate 與 identity 範例。自有 CA 用來驗證伺服器;Client Certificate/Private Key 則供伺服器驗證用戶端,兩者的責任不同。

同一份 network.rs 的 mTLS Builder 接收 CA bundle、Client Certificate 與私鑰的 PEM bytes:

pub fn mtls_client(ca_pem: &[u8], cert_pem: &[u8], key_pem: &[u8]) -> wreq::Result<Client> {
    // 這個 store 只信任所提供的 CA bundle;不是在預設 store 上追加。
    let store = CertStore::from_pem_stack(ca_pem)?;
    let identity = Identity::from_pkcs8_pem(cert_pem, key_pem)?;
    Client::builder()
        .emulation(Emulation::Chrome124)
        .no_proxy()
        .tls_cert_store(store)
        .tls_identity(identity)
        .timeout(Duration::from_secs(20))
        .build()
}

CertStore::from_pem_stack 建立的是自訂信任集合,透過 tls_cert_store 設定後會取代預設 roots。需要同時信任公開 CA 與自有 CA 時,應明確建立合併的 trust store。Identity::from_pkcs8_pem 的私鑰輸入是未加密 PKCS#8 PEM;應由受控檔案或 secret mount 提供,避免放進 repository、image 或診斷輸出。CertStore sourceIdentity source

驗證失敗時,依 CA chain、hostname、有效期間及 Client Certificate 要求排查。停用 certificate verification 會改變安全性,並不能修正錯誤的信任配置。

Tokio 併發與 Streaming

有界 Future 與取消

#[tokio::main] 建立 Runtime,.await 讓工作在等待 I/O 時交回執行權。非同步不代表無限併發;如果先為所有輸入建立並 spawn task,再在 task 裡等待 Semaphore,仍可能先累積大量 task 與輸入資料。

已知有界輸入可使用 buffer_unordered,讓同時被推進的工作數維持在設定範圍:

以下是配套 src/lib.rs 的 run 函式;Args、Client 與結果型別定義在同一檔案:

pub async fn run(args: &Args, output: &mut impl Write) -> Result<Summary, RunError> {
    let url = args.validate()?;
    let client = HttpClient::new(args).map_err(RunError::Client)?;
    let work = async {
        let mut pending = stream::iter(0..args.count)
            .map(|index| diagnose(&client, args, url.as_str(), index))
            .buffer_unordered(args.concurrency as usize);
        let mut summary = Summary::default();
        while let Some(row) = pending.next().await {
            let encoded = serde_json::to_vec(&row).map_err(RunError::Serialization)?;
            output.write_all(&encoded).map_err(RunError::Output)?;
            output.write_all(b"\n").map_err(RunError::Output)?;
            output.flush().map_err(RunError::Output)?;
            if let Some(category) = row.error {
                summary.failed += 1;
                *summary.errors.entry(category).or_default() += 1;
            } else {
                summary.success += 1;
            }
        }
        Ok(summary)
    };
    tokio::time::timeout(Duration::from_millis(args.deadline_ms), work)
        .await
        .map_err(|_| RunError::Deadline)?
}

concurrency 是最多同時執行的工作數,不是每秒請求數。例子以完成順序回傳,index 保留從 0 開始的原始序號;如果需要輸入順序,必須額外排序或使用有順序的緩衝策略,並評估等待較慢工作造成的記憶體與延遲。

join_all 會一次持有整批 Future,適合已知很小的固定集合;FuturesUnordered 可以持續加入工作,但仍需由呼叫端限制加入量。Semaphore 適合在不同呼叫路徑間共用額度,應在 spawn 前取得 permit,或讓 producer 本身受到限制。

單筆失敗可以轉成結果列,讓其餘請求繼續;配套的全批次 deadline 則由外層 timeout 控制。Tokio timeout 採合作式取消,必須在 Future 交回執行權時才能生效。配套 CLI 的 stdout/檔案輸出使用同步 Write,所以批次 deadline 不能強制中斷阻塞中的寫入;JSON 解析也另以 Body 上限約束。

Drop 一個未完成的 request Future,會停止該 Future 後續被 poll;已經送到伺服器的操作仍可能完成,所以取消不能當成遠端交易回滾。Tokio timeoutbuffer_unordered

Streaming Download 與檔案提交

大檔案不宜用 bytes() 一次收進記憶體。bytes_stream() 可以逐 chunk 讀取,配合 tokio::fs::File 寫入檔案;應用程式等待寫入完成後才讀下一塊,可避免自行建立無界 queue。

以下函式擷取自 download.rs,完整檔案另含命令列執行入口:

use futures_util::StreamExt;
use std::{io, path::Path, time::Duration};
use tokio::io::AsyncWriteExt;
use wreq::Client;
use wreq_util::Emulation;

type Error = Box<dyn std::error::Error + Send + Sync>;

pub async fn download(
    client: &Client,
    url: &str,
    destination: &Path,
    max_bytes: u64,
) -> Result<u64, Error> {
    if max_bytes == 0 {
        return Err(
            io::Error::new(io::ErrorKind::InvalidInput, "max_bytes must be positive").into(),
        );
    }
    let response = client.get(url).send().await?.error_for_status()?;
    if !response.status().is_success() {
        return Err(io::Error::other(format!(
            "download requires a 2xx response, received {}",
            response.status()
        ))
        .into());
    }
    let parent = destination
        .parent()
        .filter(|p| !p.as_os_str().is_empty())
        .unwrap_or(Path::new("."));
    // 與目標放在同一個目錄,persist 才能使用同檔案系統的原子替換。
    let temporary = tempfile::NamedTempFile::new_in(parent)?;
    let mut output = tokio::fs::File::from_std(temporary.reopen()?);
    let mut stream = response.bytes_stream();
    let mut received = 0_u64;

    while let Some(chunk) = stream.next().await {
        let chunk = chunk?;
        received = received
            .checked_add(chunk.len() as u64)
            .filter(|size| *size <= max_bytes)
            .ok_or_else(|| io::Error::other("download exceeds max_bytes"))?;
        output.write_all(&chunk).await?;
    }
    output.flush().await?;
    output.sync_all().await?;
    drop(output);
    temporary
        .persist(destination)
        .map_err(|error| error.error)?;
    Ok(received)
    // 提早回傳錯誤時,NamedTempFile drop 會移除暫存檔。
}

下載函式先要求最終狀態為 2xx,再開始寫入暫存檔。error_for_status() 只拒絕 4xx/5xx,單獨使用它可能將未追蹤的 3xx 回應頁當成下載內容。

max_bytes 限制解壓縮後實際交給程式的 Body bytes,不能只看 Content-Length。這個限制也不等於整個程序的精確記憶體上限:協定、TLS 與解碼器本身仍有 buffer,單一 chunk 也可能在檢查前已經配置。

暫存檔必須建立於目的檔案相同的檔案系統,完成下載後再提交,才能使用原子替換。在原子替換前發生錯誤或 Future 正常被 drop 時,原有目的檔案保持完整,NamedTempFile 會嘗試刪除暫存檔。Drop 中的刪除失敗不會回傳錯誤;程序遭 SIGTERM/SIGKILL 終止、直接 exit 或機器斷電,也可能留下暫存檔,需要由後續清理流程處理。NamedTempFile 清理語意。原子替換處理的是讀者不會看到半份內容;斷電耐久性還涉及檔案與目錄同步,兩者需分開設計。

如果有可信來源的 SHA-256 等 checksum,可在逐 chunk 寫入時同步計算,確認相符後再替換。TLS 保護傳輸,來源提供的內容完整性契約則決定是否還需要額外 checksum。

Streaming Upload 與 Multipart

以下函式擷取自 upload.rs,完整檔案另含命令列執行入口:

use std::{io, path::Path, time::Duration};
use tokio_util::io::ReaderStream;
use wreq::{Body, Client, Response, multipart};
use wreq_util::Emulation;

type Error = Box<dyn std::error::Error + Send + Sync>;

fn require_success(response: Response) -> Result<Response, Error> {
    let response = response.error_for_status()?;
    if !response.status().is_success() {
        return Err(io::Error::other(format!(
            "upload requires a 2xx response, received {}",
            response.status()
        ))
        .into());
    }
    Ok(response)
}

pub async fn multipart_file(client: &Client, url: &str, path: &Path) -> Result<Response, Error> {
    let form = multipart::Form::new()
        .text("description", "article upload")
        .file("file", path)
        .await?;
    require_success(client.post(url).multipart(form).send().await?)
}

pub async fn stream_file(client: &Client, url: &str, path: &Path) -> Result<Response, Error> {
    let file = tokio::fs::File::open(path).await?;
    // 來源檔案在傳輸期間必須維持不變,才可使用 metadata 的長度。
    let length = file.metadata().await?.len();
    let body = Body::wrap_stream(ReaderStream::new(file));
    require_success(
        client
            .put(url)
            .header("content-type", "application/octet-stream")
            .header("content-length", length)
            .body(body)
            .send()
            .await?,
    )
}

ReaderStream 將非同步檔案讀取轉成 stream,Body::wrap_stream 再將它交給 HTTP 層。Multipart 的 Part 可設定檔名與 MIME type;這些 metadata 應依實際內容提供。當 stream 長度未知時,也要確認服務端接受對應的傳輸方式。

上傳範例也明確要求最終 2xx。若收到 307/308,而串流 Body 無法重播,wreq 可能直接回傳該重導回應;send() 成功與 error_for_status() 通過都不足以表示上傳已完成。

stream 不是自動可重播的 Body。Retry 或保留 Body 的 Redirect 必須重新開啟檔案、重建 stream,並確認輸入內容未在兩次傳輸間改變。對已產生副作用的上傳,還需要伺服器配合 idempotency key 或其他去重契約。

WebSocket 與連線狀態

啟用 ws feature 後,client.websocket(...) 可沿用 Client 的網路、Cookie 與 Emulation 設定建立握手。預設走 HTTP/1.1 Upgrade;HTTP/2 WebSocket 需要額外的 Extended CONNECT 與伺服器能力,不能因一般 GET 能使用 HTTP/2 就推定 WebSocket 也相同。

以下函式擷取自 websocket.rs,完整檔案另含命令列執行入口:

use futures_util::SinkExt;
use std::{io, time::Duration};
use tokio::time::timeout;
use wreq::{Client, ws::message::Message};
use wreq_util::Emulation;

type Error = Box<dyn std::error::Error + Send + Sync>;

pub async fn exchange(client: &Client, url: &str) -> Result<(), Error> {
    timeout(Duration::from_secs(15), async {
        let mut socket = client
            .websocket(url)
            .max_message_size(64 * 1024)
            .max_frame_size(16 * 1024)
            .send()
            .await?
            .into_websocket()
            .await?;

        socket.send(Message::text("hello")).await?;
        socket.send(Message::binary(vec![1, 2, 3])).await?;
        socket.send(Message::ping(vec![9])).await?;
        let (mut text, mut binary, mut pong) = (false, false, false);
        while !(text && binary && pong) {
            let message = socket
                .recv()
                .await
                .ok_or_else(|| io::Error::other("peer closed early"))??;
            match message {
                Message::Text(value) => text |= value.as_str() == "hello",
                Message::Binary(value) => binary |= value.as_ref() == [1, 2, 3],
                Message::Pong(value) => pong |= value.as_ref() == [9],
                // tungstenite 自動排入 Pong;flush 確保及時送出。
                Message::Ping(_) => socket.flush().await?,
                Message::Close(_) => return Err(io::Error::other("peer closed before echo").into()),
            }
        }

        // 保留 socket,送出 Close 後繼續讀取對端的 Close 回覆。
        socket.send(Message::Close(None)).await?;
        loop {
            match socket.recv().await {
                Some(Ok(Message::Close(_))) => break,
                Some(Ok(_)) => continue,
                Some(Err(error)) => return Err(error.into()),
                None => return Err(io::Error::other("missing peer Close reply").into()),
            }
        }
        Ok::<(), Error>(())
    })
    .await??;
    Ok(())
}

Client 的 HTTP request timeout 不能當作整條 WebSocket 連線的工作期限。into_websocket() 另行等待升級,之後的訊息 stream 不再經過一般 Response Body timer;範例的外層 15 秒 timeout 因此包住握手、升級、訊息交換與 Close 確認。

send 是非同步操作,recv 回傳 Option<Result<Message, Error>>None 表示 stream 已結束,Err 則是讀取或協定錯誤。Text 與 Binary 應分開處理;Text 在這個版本是 UTF-8 型別,Binary 是 bytes。

長時間連線還需要處理 Ping/Pong、Close 與讀寫 deadline。呼叫送出 Close 或 close(...),不等於已確認對端完成關閉握手;如果業務需要確認,應在有限時間內持續讀取對端 Close。控制訊息與正常資料訊息也不應混成同一種訂閱事件。WebSocket source

需要同時讀寫時,可使用 Stream/Sink 的 split 模型,把輸出訊息經有界 channel 交給寫入工作;channel 滿時採等待、拒絕或依業務規則合併,避免無限排隊。訊息大小與排隊訊息數是兩種限制,都需要設定。

重新連線會建立新的 transport connection,舊連線的 subscription 與已確認進度不會自動恢復。可靠處理通常需要保存 cursor/sequence、重送訂閱、辨識重複訊息,再由應用層確認接收進度。Profile 負責握手特徵,這些恢復規則仍屬於業務協定。

Error、Retry 與診斷 CLI

錯誤分類與敏感資訊

傳輸錯誤、HTTP status 與 JSON 契約錯誤,需要保留不同意義:

類別 例子 處理方向
Build/input 非法 URI、錯誤 Client 配置、缺少憑證 在發送前拒絕
Connect/TLS/Proxy DNS 失敗、無法建立連線、驗證失敗 保留內部 source,依設定或網路原因排查
Timeout 連線、Body 或工作 deadline 超時 判斷是否可安全重試及剩餘預算
HTTP status 401、429、503 依狀態碼與 API 契約處理
Body/decode stream 中斷、無效 JSON 區分傳輸損壞與格式不符
Schema 指紋欄位型別錯誤、沒有可用診斷欄位 拒絕把任意 JSON 當成成功結果
Output 磁碟或 stdout 寫入失敗 停止輸出並以非零碼結束

兩個 library 的分類能力不同。wreq 能辨識部分 DNS、TLS、Proxy 錯誤,但 is_tls() 沒有涵蓋所有包在連線錯誤內的 TLS handshake/憑證失敗;這些情況仍可能回到 connect_errorreqwest 的公開 predicates 也無法完整分拆 DNS/TLS,不能用 is_connect() 就宣稱已精確判定根因。內部可以用 thiserror 保存原始 source,外部輸出則採穩定分類;anyhow 適合執行入口整合不同錯誤,但不應把完整 error chain 直接當成公開診斷內容。

reqwest::Error::without_url() 與 wreq::Error::without_uri() 可以移除 URL/URI,卻不保證其他 source、Header 或自訂訊息已完全去敏。診斷紀錄採欄位白名單,比直接印出整個 Request、Response 或 Debug 物件更容易控制輸出契約。

Retry Budget 與冪等性

診斷 CLI 每筆 GET 只嘗試一次,並停用 library 的隱含協定重試,讓每列結果直接對應一次應用層發送。若業務需要 Retry,應在同一個工作 deadline 內安排每次嘗試,而不是每次重新取得完整預算。

決策條件 Retry 策略
暫時性連線錯誤,操作可重送 在剩餘時間與次數額度內重試
429/503 含 Retry-After 解析秒數或 HTTP-date;等待要求超過預算時結束
沒有 Retry-After 的可重試錯誤 有上限的 exponential backoff 加 jitter
憑證驗證、認證或輸入格式錯誤 修正根因,通常不直接重試
POST/上傳結果不明 先核對服務端冪等性與去重契約
Body 無法重建 停止重送,不能重用已消耗的 stream

伺服器要求等待 120 秒,工作只剩 10 秒時,不能把等待縮成 10 秒就提前重送;應回報本次預算不足。GET 通常適合有限重試,仍應確認端點行為;POST 即使帶了自己產生的 idempotency key,也必須由伺服器實際支援去重才有效。HTTP Retry-AfterHTTP 冪等方法

Diagnostic CLI 與 JSON Lines

配套 fingerprint-client-rs 將四組 Client、併發限制、Body 上限、錯誤分類與 JSON Lines 輸出整合為有限期命令列程式。它接收診斷端點,逐筆完成 GET 與欄位驗證,執行完畢後結束。

以下使用固定 Windows Chrome 124 Profile,最多同時執行兩筆 GET,將三筆結果寫入新檔案:

cargo run --locked -- \
  --url https://tls.browserleaks.com/json \
  --client wreq-chrome \
  --profile chrome124 \
  --count 3 \
  --concurrency 2 \
  --timeout-ms 10000 \
  --deadline-ms 60000 \
  --max-bytes 65536 \
  --output result.jsonl

timeout-ms 是每筆工作開始後的毫秒上限,deadline-ms 是 Client 建立後整批非同步工作的毫秒上限,max-bytes 是單筆 Body 累積大小上限。--output 使用新建檔案模式,拒絕覆寫既有檔案;預設 - 則寫入 stdout。

每列包含 index、Client/Profile/Platform、HTTP status、HTTP version、latency_msattemptserror 與 fingerprintlatency_ms 不含尚未開始執行的排隊時間;attempts=1 表示一次應用層嘗試,不代表一個封包或一條 TCP 連線。

端點必須回傳頂層 JSON object。CLI 只接受 ja3_hashja3n_hashja4 與 akamai_hash 四個診斷欄位,其中 Hash 要求 32 個小寫十六進位字元,JA4 採固定格式檢查。只有空字串 akamai_hash 可以視為缺少 HTTP/2 資料;解析後的任何已知欄位若是 null、錯誤型別或無效格式,整筆都會拒絕,且至少需要一個有效診斷欄位。其他欄位全部略過。JSON 使用 serde_json::Value 解析,原始物件若有重複鍵,會採用最後一個值,再執行上述驗證。

error=null 代表 HTTP 與診斷格式檢查成功;尚未收到 Headers 時,status/version 是 null。失敗列只保留固定錯誤分類與已取得的 metadata,不輸出完整 URL、IP、Cookie、Header 或原始 Body。格式通過只能確認符合資料契約,不能代替對診斷端點與 Profile 結果的信任判斷。

正常跑完整批後,stderr 另輸出成功數、失敗數與錯誤分類統計。Exit code 0 代表全部成功,1 代表批次完成但有請求失敗,2 則代表參數、Client 建立、輸出或批次 deadline 造成中止。中止時 JSONL 可能只包含部分結果;輸出 I/O 失敗甚至可能留下不完整的最後一行。

輸出順序依完成時間決定,以 index 對回原始請求。公開服務可能改變回傳欄位或暫時不可用;出現缺少診斷欄位時,先核對 schema 與 HTTP 回應,不應把任意成功 JSON 當成已取得指紋。

Python 與 Rust 的整合取捨

面向 Python curl_cffi Rust wreq
底層 CFFI、libcurl-impersonate Rust HTTP stack、btls/BoringSSL
API Requests-like、Session Builder、Future、型別化資料
Async AsyncSession、libcurl Multi Tokio Runtime 與 Future
共用狀態 Session、Cookie 與 Curl handles Client clone、connection pool、Cookie Store
Profile impersonate Target Browser Profile+Platform
HTTP/3 固定實測版本有對應能力 wreq 0.16.1 尚無可用傳輸實作
部署 Python runtime 與 wheel 編譯後 binary,加上必要 runtime libraries/CA
維護重點 Python 與底層 libcurl 版本 Rust toolchain、Cargo.lock 與 native build

curl_cffi 的具體用法可對照 Python Browser Impersonation、Asyncio 與 WebSocket。兩者即使使用相同 Browser 名稱,也不代表同一份 Profile 資料或相同封包;比較時應同時固定版本、平台、網路與診斷方法。

選型應配合既有系統與部署條件。Python 方便接入既有資料處理與自動化流程;Rust 便於把型別、非同步工作與資源生命週期整合到原生服務。吞吐量與延遲需要分別量測冷連線、暖連線、payload 與併發,不能只依語言名稱推定。

配套建置與驗收

在已下載的 rust-wreq/ 目錄中,依 Rust README 執行:

cargo fmt --all -- --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
cargo build --locked --release

配套的 Dockerfile 固定 Rust 1.98.0 與 Debian Bookworm 編譯環境,runtime image 以非 root 身分執行 CLI。以下指令驗證建置、Compose 設定與啟動:

docker build -t fingerprint-client-rs .
docker compose config
docker compose up --abort-on-container-exit --exit-code-from fingerprint-client
docker run --rm fingerprint-client-rs --help

固定環境下完成的功能驗證如下;Rust README 另列出環境需求、指令與測試範圍。

驗證項目 結果
fmt/Clippy 格式檢查通過,all-targets 沒有 Clippy warning
Diagnostic CLI 14 項本機測試通過,包含四組 URL 正規化一致性、錯誤分類、逾時、解壓後上限與輸出失敗
HTTP/Streaming/WebSocket 8 項功能測試通過,直接呼叫正文配套函式,包含非 2xx 拒絕與下載取消清理
read_timeout 邊界 2 項測試通過,確認 Headers 分段到達不重設計時,以及 Body 消費暫停可能觸發逾時
最小 Quick Start 僅開頭四個依賴即可編譯執行,實際回傳 HTTP/2 與 JA3N
公開指紋對照 四組 Client 各發送一筆 GET,皆回傳 200/HTTP/2
release/Docker release build、Compose 設定與啟動通過;runtime HTTPS 診斷為 200/HTTP/2

正文程式另由 python3 verify_article.py 確認與配套原始碼一致。Proxy 與 mTLS 範例已編譯,但沒有實際代理連線或雙向 TLS 握手驗收;Wireshark 解密、真實 Chrome 封包對照、WebSocket 自動重連及跨平台 benchmark 也不屬於這次已完成的驗證。