Skip to main content

futu_backend/auth/
http_client.rs

1//! v1.4.110+ Tier 1 split (from `auth/mod.rs`): reqwest HTTP client builder.
2//!
3//! - `build_http_client(client_type)` — 主入口
4//! - `build_http_client_with_resolve(client_type, resolve)` — 测试 / IP override 入口
5//! - `auth_http_default_headers` — AuthIPList / commconfig 专用 `X-Futu-*` headers
6//! - `auth_business_headers` — FTAuthImpl 业务鉴权请求 headers
7//!
8//! TLS: rustls-tls-webpki-roots (CLAUDE.md 坑 #50, 防 user keychain MITM).
9
10use futu_core::error::{FutuError, Result};
11
12pub(crate) const AUTH_HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
13pub(crate) const AUTH_HTTP_MAX_REDIRECTS: usize = 3;
14pub(crate) const PRIMARY_AUTH_HTTP_MAX_REDIRECTS: usize = 6;
15
16pub fn build_http_client(client_type: u8) -> Result<reqwest::Client> {
17    build_http_client_with_resolve(client_type, None)
18}
19
20pub(crate) fn build_http_client_with_resolve(
21    client_type: u8,
22    resolve: Option<(&str, std::net::SocketAddr)>,
23) -> Result<reqwest::Client> {
24    build_http_client_with_policy(
25        client_type,
26        resolve,
27        AuthHttpRedirectPolicy::SharedLimited(AUTH_HTTP_MAX_REDIRECTS),
28    )
29}
30
31pub(crate) fn build_primary_auth_http_client(client_type: u8) -> Result<reqwest::Client> {
32    build_primary_auth_http_client_with_resolve(client_type, None)
33}
34
35pub(crate) fn build_primary_auth_http_client_with_resolve(
36    client_type: u8,
37    resolve: Option<(&str, std::net::SocketAddr)>,
38) -> Result<reqwest::Client> {
39    build_http_client_with_policy(
40        client_type,
41        resolve,
42        AuthHttpRedirectPolicy::PrimaryRequesterOwned,
43    )
44}
45
46enum AuthHttpRedirectPolicy {
47    SharedLimited(usize),
48    PrimaryRequesterOwned,
49}
50
51fn build_http_client_with_policy(
52    client_type: u8,
53    resolve: Option<(&str, std::net::SocketAddr)>,
54    redirect_policy: AuthHttpRedirectPolicy,
55) -> Result<reqwest::Client> {
56    // v1.4.84 SEC-002 主修复 (external reviewer security report):
57    //
58    // **删除** 之前的 `.danger_accept_invalid_certs(true)` — 这相当于对所有
59    // HTTPS endpoint 完全**禁用** cert 验证, 任何 MITM 都能过. external reviewer 实证:
60    // mitmproxy CA 装入 user keychain 后 Rust daemon 10 个 HTTPS endpoint
61    // 全部 TLS 握手成功, 同条件 C++ OpenD 被 `tlsv1 alert unknown ca` 拒.
62    //
63    // 配合 `futu_core::http_client::webpki_builder()`:
64    // - 排除 native-tls (OS keychain trust, 会被 user keychain 恶意 CA MITM)
65    // - 使用 Mozilla webpki-roots (curated CA list, 不读 user keychain)
66    //
67    // **防攻击面**:
68    // - Agent skill 装 user keychain MITM CA → 不再被信任, 握手失败
69    // - 企业 MDM 推入系统 / 用户 trust store 的 CA → 同样不会被读取;只有
70    //   Mozilla webpki-roots 内的公开 CA 会被信任
71    //
72    // **注**: Futu backend 用公开 CA 签 cert, webpki-roots 内置全球公共 CA,
73    // 正常握手不受影响.
74    let _ = client_type;
75    // Ref:
76    // - shared/support default: `FTNet/Src/ftnet/ftnet_def.h:522-525`;
77    // - primary business override:
78    //   `FTLogin/Src/ftlogin/auth/impl/auth_impl.cpp:3648-3649`.
79    // Both retain the same 10-second Rust request budget. Shared/support
80    // redirect ownership remains here (3); primary business redirect
81    // ownership lives in `LivePrimaryAuthHttpRequester` (6) so POST/body can
82    // match `CURL_REDIR_POST_ALL`.
83    let redirect_policy = match redirect_policy {
84        AuthHttpRedirectPolicy::SharedLimited(max_redirects) => {
85            reqwest::redirect::Policy::limited(max_redirects)
86        }
87        AuthHttpRedirectPolicy::PrimaryRequesterOwned => reqwest::redirect::Policy::none(),
88    };
89    let mut builder = futu_core::http_client::webpki_builder()
90        .timeout(AUTH_HTTP_TIMEOUT)
91        .redirect(redirect_policy);
92    if let Some((domain, addr)) = resolve {
93        builder = builder.resolve(domain, addr);
94    }
95    builder
96        .build()
97        .map_err(|e| FutuError::Encryption(format!("http client: {e}")))
98}
99
100/// Headers for `AuthIPList::UpdateAuthIPList` and related config fetches.
101///
102/// C++ auth business requests do **not** use this set. They go through
103/// `FTAuthImpl::InitRequest` / `SetHttpHeaders` / `SendRequestImpl`, which only
104/// carries Content-Type, Cookie and OpenD's User-Agent at the request layer.
105/// Ref:
106/// - `FTlogin/Src/ftlogin/auth/impl/auth_ip_list.cpp:263-281`
107/// - `FTlogin/Src/ftlogin/auth/impl/auth_impl.cpp:3416-3423,3606-3612,3772-3775`
108pub(crate) fn auth_http_default_headers(client_type: u8) -> Result<reqwest::header::HeaderMap> {
109    let mut default_headers = reqwest::header::HeaderMap::new();
110    default_headers.insert(
111        "X-Futu-Client-Type",
112        http_header_value("X-Futu-Client-Type", client_type)?,
113    );
114    default_headers.insert(
115        "X-Futu-Client-Version",
116        http_header_value(
117            "X-Futu-Client-Version",
118            crate::conn::BackendConn::CLIENT_VER_FTGTW,
119        )?,
120    );
121    default_headers.insert(
122        "X-Futu-Client-Lang",
123        reqwest::header::HeaderValue::from_static("sc"),
124    );
125    default_headers.insert(
126        "Content-Type",
127        reqwest::header::HeaderValue::from_static("application/json"),
128    );
129    Ok(default_headers)
130}
131
132pub(crate) fn auth_business_headers(
133    client_type: u8,
134    device_id: &str,
135) -> Result<reqwest::header::HeaderMap> {
136    let mut headers = reqwest::header::HeaderMap::new();
137    headers.insert(
138        reqwest::header::CONTENT_TYPE,
139        reqwest::header::HeaderValue::from_static("application/json"),
140    );
141    headers.insert(
142        reqwest::header::COOKIE,
143        http_header_value("Cookie", format!("device_id={device_id}"))?,
144    );
145    headers.insert(
146        reqwest::header::USER_AGENT,
147        http_header_value("User-Agent", opend_auth_user_agent(client_type))?,
148    );
149    AuthTraceHeaders::new().insert_http_headers(&mut headers)?;
150    Ok(headers)
151}
152
153#[derive(Debug, Clone)]
154pub(crate) struct AuthTraceHeaders {
155    trace_id: String,
156    parent_span_id: String,
157    span_id: String,
158}
159
160impl AuthTraceHeaders {
161    pub(crate) fn new() -> Self {
162        // Ref: `FTlogin/Src/ftlogin/auth/impl/auth_impl.cpp:3596-3600`
163        // and `FTNet/Src/ftnet_unittest/ftnet_unittest.cpp:20-24`.
164        // These IDs are observability-only B3 headers, not idempotency keys.
165        Self {
166            trace_id: hex::encode(rand::random::<[u8; 16]>()),
167            parent_span_id: hex::encode(rand::random::<[u8; 8]>()),
168            span_id: hex::encode(rand::random::<[u8; 8]>()),
169        }
170    }
171
172    pub(crate) fn entries(&self) -> [(&'static str, &str); 3] {
173        [
174            ("x-b3-traceid", self.trace_id.as_str()),
175            ("x-b3-parentspanid", self.parent_span_id.as_str()),
176            ("x-b3-spanid", self.span_id.as_str()),
177        ]
178    }
179
180    fn insert_http_headers(&self, headers: &mut reqwest::header::HeaderMap) -> Result<()> {
181        for (name, value) in self.entries() {
182            headers.insert(name, http_header_value(name, value)?);
183        }
184        Ok(())
185    }
186}
187
188pub(crate) fn opend_auth_user_agent(client_type: u8) -> String {
189    // Ref: FutuOpenD/Src/NNProtoCenter/Login/NNDataUrl.cpp:252-266.
190    format!(
191        "ClientType/{client_type} ClientVersion/{} CliLang/zh-cn ClientHourClock/24 OsType/{} RequestSource/Http",
192        crate::conn::BackendConn::CLIENT_VER_FTGTW,
193        opend_user_agent_os_type(),
194    )
195}
196
197fn opend_user_agent_os_type() -> &'static str {
198    if cfg!(target_os = "macos") {
199        "11"
200    } else if cfg!(target_os = "linux") {
201        "14"
202    } else {
203        "10"
204    }
205}
206
207fn http_header_value(
208    name: &'static str,
209    value: impl std::fmt::Display,
210) -> Result<reqwest::header::HeaderValue> {
211    reqwest::header::HeaderValue::from_str(&value.to_string())
212        .map_err(|e| FutuError::Codec(format!("{name}: invalid header value: {e}")))
213}