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 fn build_http_client(client_type: u8) -> Result<reqwest::Client> {
13    build_http_client_with_resolve(client_type, None)
14}
15
16pub(crate) fn build_http_client_with_resolve(
17    client_type: u8,
18    resolve: Option<(&str, std::net::SocketAddr)>,
19) -> Result<reqwest::Client> {
20    // v1.4.84 SEC-002 主修复 (external reviewer security report):
21    //
22    // **删除** 之前的 `.danger_accept_invalid_certs(true)` — 这相当于对所有
23    // HTTPS endpoint 完全**禁用** cert 验证, 任何 MITM 都能过. external reviewer 实证:
24    // mitmproxy CA 装入 user keychain 后 Rust daemon 10 个 HTTPS endpoint
25    // 全部 TLS 握手成功, 同条件 C++ OpenD 被 `tlsv1 alert unknown ca` 拒.
26    //
27    // 配合 workspace Cargo.toml `reqwest` dep 改为 `rustls-tls-webpki-roots`:
28    // - 排除 native-tls (OS keychain trust, 会被 user keychain 恶意 CA MITM)
29    // - 使用 Mozilla webpki-roots (curated CA list, 不读 user keychain)
30    //
31    // **防攻击面**:
32    // - Agent skill 装 user keychain MITM CA → 不再被信任, 握手失败
33    // - 企业 MDM 推 CA → 仍会被信任 (MDM 是 system-trusted), 若需阻挡
34    //   此类场景需 v1.4.85+ 加 cert-pinning (SPKI hash) for 敏感 endpoint
35    //
36    // **注**: Futu backend 用公开 CA 签 cert, webpki-roots 内置全球公共 CA,
37    // 正常握手不受影响.
38    let _ = client_type;
39    let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(15));
40    if let Some((domain, addr)) = resolve {
41        builder = builder.resolve(domain, addr);
42    }
43    builder
44        .build()
45        .map_err(|e| FutuError::Encryption(format!("http client: {e}")))
46}
47
48/// Headers for `AuthIPList::UpdateAuthIPList` and related config fetches.
49///
50/// C++ auth business requests do **not** use this set. They go through
51/// `FTAuthImpl::InitRequest` / `SetHttpHeaders` / `SendRequestImpl`, which only
52/// carries Content-Type, Cookie and OpenD's User-Agent at the request layer.
53/// Ref:
54/// - `FTlogin/Src/ftlogin/auth/impl/auth_ip_list.cpp:263-281`
55/// - `FTlogin/Src/ftlogin/auth/impl/auth_impl.cpp:3416-3423,3606-3612,3772-3775`
56pub(crate) fn auth_http_default_headers(client_type: u8) -> Result<reqwest::header::HeaderMap> {
57    let mut default_headers = reqwest::header::HeaderMap::new();
58    default_headers.insert(
59        "X-Futu-Client-Type",
60        http_header_value("X-Futu-Client-Type", client_type)?,
61    );
62    default_headers.insert(
63        "X-Futu-Client-Version",
64        http_header_value(
65            "X-Futu-Client-Version",
66            crate::conn::BackendConn::CLIENT_VER_FTGTW,
67        )?,
68    );
69    default_headers.insert(
70        "X-Futu-Client-Lang",
71        reqwest::header::HeaderValue::from_static("sc"),
72    );
73    default_headers.insert(
74        "Content-Type",
75        reqwest::header::HeaderValue::from_static("application/json"),
76    );
77    Ok(default_headers)
78}
79
80pub(crate) fn auth_business_headers(
81    client_type: u8,
82    device_id: &str,
83) -> Result<reqwest::header::HeaderMap> {
84    let mut headers = reqwest::header::HeaderMap::new();
85    headers.insert(
86        reqwest::header::CONTENT_TYPE,
87        reqwest::header::HeaderValue::from_static("application/json"),
88    );
89    headers.insert(
90        reqwest::header::COOKIE,
91        http_header_value("Cookie", format!("device_id={device_id}"))?,
92    );
93    headers.insert(
94        reqwest::header::USER_AGENT,
95        http_header_value("User-Agent", opend_auth_user_agent(client_type))?,
96    );
97    AuthTraceHeaders::new().insert_http_headers(&mut headers)?;
98    Ok(headers)
99}
100
101#[derive(Debug, Clone)]
102pub(crate) struct AuthTraceHeaders {
103    trace_id: String,
104    parent_span_id: String,
105    span_id: String,
106}
107
108impl AuthTraceHeaders {
109    pub(crate) fn new() -> Self {
110        // Ref: `FTlogin/Src/ftlogin/auth/impl/auth_impl.cpp:3596-3600`
111        // and `FTNet/Src/ftnet_unittest/ftnet_unittest.cpp:20-24`.
112        // These IDs are observability-only B3 headers, not idempotency keys.
113        Self {
114            trace_id: hex::encode(rand::random::<[u8; 16]>()),
115            parent_span_id: hex::encode(rand::random::<[u8; 8]>()),
116            span_id: hex::encode(rand::random::<[u8; 8]>()),
117        }
118    }
119
120    pub(crate) fn entries(&self) -> [(&'static str, &str); 3] {
121        [
122            ("x-b3-traceid", self.trace_id.as_str()),
123            ("x-b3-parentspanid", self.parent_span_id.as_str()),
124            ("x-b3-spanid", self.span_id.as_str()),
125        ]
126    }
127
128    fn insert_http_headers(&self, headers: &mut reqwest::header::HeaderMap) -> Result<()> {
129        for (name, value) in self.entries() {
130            headers.insert(name, http_header_value(name, value)?);
131        }
132        Ok(())
133    }
134}
135
136pub(crate) fn opend_auth_user_agent(client_type: u8) -> String {
137    // Ref: FutuOpenD/Src/NNProtoCenter/Login/NNDataUrl.cpp:252-266.
138    format!(
139        "ClientType/{client_type} ClientVersion/{} CliLang/zh-cn ClientHourClock/24 OsType/{} RequestSource/Http",
140        crate::conn::BackendConn::CLIENT_VER_FTGTW,
141        opend_user_agent_os_type(),
142    )
143}
144
145fn opend_user_agent_os_type() -> &'static str {
146    if cfg!(target_os = "macos") {
147        "11"
148    } else if cfg!(target_os = "linux") {
149        "14"
150    } else {
151        "10"
152    }
153}
154
155fn http_header_value(
156    name: &'static str,
157    value: impl std::fmt::Display,
158) -> Result<reqwest::header::HeaderValue> {
159    reqwest::header::HeaderValue::from_str(&value.to_string())
160        .map_err(|e| FutuError::Codec(format!("{name}: invalid header value: {e}")))
161}