diff --git a/Cargo.lock b/Cargo.lock index ff74983ad..03a487371 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -46,6 +52,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -95,6 +116,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -227,6 +260,27 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "brotli" +version = "8.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -252,6 +306,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -347,6 +403,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -500,6 +576,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -743,6 +828,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.12.0" @@ -1330,6 +1425,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.100" @@ -1457,6 +1562,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -2257,6 +2372,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "similar" version = "2.7.0" @@ -2759,13 +2880,17 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ + "async-compression", "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", "pin-project-lite", "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -3575,3 +3700,31 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 5feefede9..324cd008c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ dotenvy = "0.15.7" futures = "0.3.32" hmac = "0.13.0" rand = "0.10.1" -reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots"] } +reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots", "gzip", "brotli", "zstd", "deflate"] } rustls = { version = "=0.23.36", default-features = false, features = ["ring", "std", "tls12"] } secrecy = "0.10.3" serde = { version = "1.0.228", features = ["derive"] } diff --git a/docs/dependency-policy.md b/docs/dependency-policy.md index 21bd16e8c..3fb4bdb23 100644 --- a/docs/dependency-policy.md +++ b/docs/dependency-policy.md @@ -4,6 +4,7 @@ - Commit `Cargo.lock` because this is an application. - Run `cargo update` in dedicated dependency-refresh changes. - Keep `reqwest = 0.12.28` and `rustls = 0.23.36` pinned until a TLS fingerprint review proves a newer pair matches real Codex Desktop behavior. +- `reqwest` is built with `rustls-tls-native-roots`, `json`, `stream`, `cookies`, and response compression features used by the Codex client builder. Do not switch back to default native-tls. - Do not add unmaintained crates when a maintained community-standard crate exists. - Every dependency change must run: diff --git a/docs/implementation-status.md b/docs/implementation-status.md index ff32aeeca..c97369c03 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -11,7 +11,7 @@ Update this file before each feature commit. | Admin auth and client API keys | Completed | pending auth commit | `cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash`; `cargo test admin_password_hash_is_not_a_client_api_key`; full `fmt/test/clippy` before commit | Admin passwords use Argon2id, client API keys use `cpr_` prefix plus HMAC-SHA256 with server-side pepper. | | Secret encryption | Completed | pending crypto commit | `cargo test secret_box_encrypts_and_decrypts_without_storing_plaintext`; full `fmt/test/clippy` before commit | AES-256-GCM `SecretBox` stores upstream secrets as `v1::` without plaintext. | | Logging and pagination | Completed | pending logs commit | `cargo test event_logs_are_cursor_paginated`; full `fmt/test/clippy` before commit | Adds cursor pagination, `Page` camelCase serialization, event log repository, and rotation config shell. | -| TLS headers and fingerprint | Planned | | | | +| TLS headers and fingerprint | Completed | pending codex headers commit | `cargo test codex_headers_include_desktop_identity_and_turn_state`; `cargo tree \| rg 'reqwest\|rustls'`; full `fmt/test/clippy` before commit | Adds Codex Desktop fingerprint model, exact identity/request headers, and pinned reqwest/rustls client builder. | | Cookie persistence | Planned | | | | | Account pool and refresh | Planned | | | | | Translation | Planned | | | | diff --git a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md index a26fbdbff..b0436e7f1 100644 --- a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md +++ b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md @@ -1745,7 +1745,7 @@ git commit -m "feat: add paginated event logs" - Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs` - Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/codex_headers_test.rs` -- [ ] **Step 1: Write failing header test** +- [x] **Step 1: Write failing header test** Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/codex_headers_test.rs`: @@ -1769,7 +1769,7 @@ fn codex_headers_include_desktop_identity_and_turn_state() { } ``` -- [ ] **Step 2: Run test and verify RED** +- [x] **Step 2: Run test and verify RED** Run: @@ -1779,7 +1779,7 @@ cargo test codex_headers_include_desktop_identity_and_turn_state Expected: compile failure because `codex` and `fingerprint` modules do not exist. -- [ ] **Step 3: Implement fingerprint and headers** +- [x] **Step 3: Implement fingerprint and headers** Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`: @@ -1908,7 +1908,7 @@ Create empty module files: touch src/fingerprint/updater.rs src/codex/sse.rs src/codex/types.rs src/codex/usage.rs src/codex/websocket.rs ``` -- [ ] **Step 4: Verify headers** +- [x] **Step 4: Verify headers** Run: @@ -1919,7 +1919,7 @@ cargo tree | rg 'reqwest|rustls' Expected: test passes; `cargo tree` shows `reqwest v0.12.28` and `rustls v0.23.36`. -- [ ] **Step 5: Commit TLS and headers foundation** +- [x] **Step 5: Commit TLS and headers foundation** ```bash git add Cargo.toml src/codex src/fingerprint src/lib.rs tests/codex_headers_test.rs diff --git a/src/codex/client.rs b/src/codex/client.rs new file mode 100644 index 000000000..e2c592202 --- /dev/null +++ b/src/codex/client.rs @@ -0,0 +1,18 @@ +use reqwest::Client; + +pub fn build_reqwest_client(force_http11: bool) -> Result { + // 中文注释:Codex Desktop 指纹依赖 reqwest/rustls 组合,升级前必须重新验证 TLS 行为。 + let builder = Client::builder() + .use_rustls_tls() + .no_proxy() + .gzip(true) + .brotli(true) + .zstd(true) + .deflate(true); + let builder = if force_http11 { + builder.http1_only() + } else { + builder + }; + builder.build() +} diff --git a/src/codex/headers.rs b/src/codex/headers.rs new file mode 100644 index 000000000..616353fe2 --- /dev/null +++ b/src/codex/headers.rs @@ -0,0 +1,32 @@ +use std::collections::BTreeMap; + +use crate::fingerprint::model::Fingerprint; + +pub fn build_codex_headers( + fp: &Fingerprint, + access_token: &str, + account_id: Option<&str>, + turn_state: Option<&str>, + request_id: &str, +) -> BTreeMap { + let mut headers = BTreeMap::new(); + headers.insert("accept".to_string(), "text/event-stream".to_string()); + headers.insert( + "authorization".to_string(), + format!("Bearer {access_token}"), + ); + headers.insert("originator".to_string(), fp.originator.clone()); + headers.insert("user-agent".to_string(), fp.user_agent()); + headers.insert("x-client-request-id".to_string(), request_id.to_string()); + headers.insert( + "x-openai-internal-codex-residency".to_string(), + "global".to_string(), + ); + if let Some(account_id) = account_id { + headers.insert("chatgpt-account-id".to_string(), account_id.to_string()); + } + if let Some(turn_state) = turn_state { + headers.insert("x-codex-turn-state".to_string(), turn_state.to_string()); + } + headers +} diff --git a/src/codex/mod.rs b/src/codex/mod.rs new file mode 100644 index 000000000..038fae8a2 --- /dev/null +++ b/src/codex/mod.rs @@ -0,0 +1,6 @@ +pub mod client; +pub mod headers; +pub mod sse; +pub mod types; +pub mod usage; +pub mod websocket; diff --git a/src/codex/sse.rs b/src/codex/sse.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/codex/sse.rs @@ -0,0 +1 @@ + diff --git a/src/codex/types.rs b/src/codex/types.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/codex/types.rs @@ -0,0 +1 @@ + diff --git a/src/codex/usage.rs b/src/codex/usage.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/codex/usage.rs @@ -0,0 +1 @@ + diff --git a/src/codex/websocket.rs b/src/codex/websocket.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/codex/websocket.rs @@ -0,0 +1 @@ + diff --git a/src/fingerprint/mod.rs b/src/fingerprint/mod.rs new file mode 100644 index 000000000..042bfbf31 --- /dev/null +++ b/src/fingerprint/mod.rs @@ -0,0 +1,2 @@ +pub mod model; +pub mod updater; diff --git a/src/fingerprint/model.rs b/src/fingerprint/model.rs new file mode 100644 index 000000000..b93ec5609 --- /dev/null +++ b/src/fingerprint/model.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Fingerprint { + pub originator: String, + pub app_version: String, + pub build_number: String, + pub platform: String, + pub arch: String, + pub chromium_version: String, + pub user_agent_template: String, +} + +impl Fingerprint { + pub fn default_for_tests() -> Self { + Self { + originator: "Codex Desktop".to_string(), + app_version: "26.519.81530".to_string(), + build_number: "3178".to_string(), + platform: "darwin".to_string(), + arch: "arm64".to_string(), + chromium_version: "146".to_string(), + user_agent_template: + "Codex/{app_version} ({platform}; {arch}) Chromium/{chromium_version}".to_string(), + } + } + + pub fn user_agent(&self) -> String { + self.user_agent_template + .replace("{app_version}", &self.app_version) + .replace("{platform}", &self.platform) + .replace("{arch}", &self.arch) + .replace("{chromium_version}", &self.chromium_version) + } +} diff --git a/src/fingerprint/updater.rs b/src/fingerprint/updater.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/fingerprint/updater.rs @@ -0,0 +1 @@ + diff --git a/src/lib.rs b/src/lib.rs index a135b88c8..b86bd4184 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,9 @@ pub mod auth; +pub mod codex; pub mod config; pub mod crypto; pub mod error; +pub mod fingerprint; pub mod logs; pub mod pagination; pub mod state; diff --git a/tests/codex_headers_test.rs b/tests/codex_headers_test.rs new file mode 100644 index 000000000..cb95497e5 --- /dev/null +++ b/tests/codex_headers_test.rs @@ -0,0 +1,20 @@ +use codex_proxy_rs::{codex::headers::build_codex_headers, fingerprint::model::Fingerprint}; + +#[test] +fn codex_headers_include_desktop_identity_and_turn_state() { + let fp = Fingerprint::default_for_tests(); + let headers = build_codex_headers( + &fp, + "access-token", + Some("acct_123"), + Some("turn-state"), + "rid_1", + ); + + assert_eq!(headers.get("originator").unwrap(), "Codex Desktop"); + assert!(headers.get("user-agent").unwrap().contains("Codex")); + assert_eq!(headers.get("authorization").unwrap(), "Bearer access-token"); + assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123"); + assert_eq!(headers.get("x-codex-turn-state").unwrap(), "turn-state"); + assert_eq!(headers.get("x-client-request-id").unwrap(), "rid_1"); +}