Skip to main content

futu_backend/auth/commconfig/
parsers.rs

1//! auth/commconfig/parsers — api helpers + fetch_page + parse_forced_ip + parse_guaranteed_ip +
2//! parse_web_tcp + parse_auth_guaranteed_domain + value_kind + is_*_identity
3//! (v1.4.110 CC Batch M: 拆自 commconfig.rs L245-664)
4
5use std::collections::HashMap;
6
7use futu_core::error::{FutuError, Result};
8
9use crate::auth::UserAttribution;
10
11use super::totp::gen_totp_sha1;
12use super::types::{
13    AUTH_TOKEN_KEY_B32, AuthGuaranteedDomainMap, CONN_WEB_AU, CONN_WEB_CA, CONN_WEB_CN,
14    CONN_WEB_HK, CONN_WEB_JP, CONN_WEB_MY, CONN_WEB_SG, CONN_WEB_US, ForcedIpEntry, ForcedIpMap,
15    GuaranteedBrokerIpMap, GuaranteedIpMap, GuaranteedWebIpMap,
16};
17
18pub fn api_root_for_client(client_type: u8) -> &'static str {
19    if client_type == 40 {
20        "https://api.futunn.com"
21    } else {
22        "https://api.moomoo.com"
23    }
24}
25
26/// `nClientVer = 1030` → `"10.30.0"` 点分形式(对齐 C++ 拼法)。
27/// BuildVer 我们统一填 0。
28pub fn client_version_dotted(num_ver: u32) -> String {
29    let major = num_ver / 100;
30    let minor = num_ver % 100;
31    format!("{major}.{minor}.0")
32}
33
34/// 单次拉取 `begin_id` 对应那一页的原始 JSON 响应。
35pub async fn fetch_page(
36    http: &reqwest::Client,
37    client_type: u8,
38    device_id: &str,
39    user_id: u64,
40    begin_id: i32,
41    svr_time_offset: i64,
42) -> Result<serde_json::Value> {
43    // v1.4.22:用服务端时间 = local + offset 作 TOTP 种子,对齐 C++
44    // `INNBiz_SvrTime::GetSvrTimeStamp()`。offset 在 salt 响应里算出,
45    // 传递到此处。机器时钟偏差 >30s 时保证 TOTP 不被拒。
46    let svr_ts = super::fetch::server_now_ts(svr_time_offset);
47    let token = gen_totp_sha1(AUTH_TOKEN_KEY_B32, svr_ts, 30)
48        .ok_or_else(|| FutuError::Encryption("commconfig: TOTP generation failed".into()))?;
49
50    let client_ver_num = crate::conn::BackendConn::CLIENT_VER_FTGTW as u32;
51    let client_ver_dotted = client_version_dotted(client_ver_num);
52
53    let url = format!(
54        "{root}/v2/conf/select_all?user_id={uid}&auth_token={tok}&is_visitor=0\
55         &clienttype={ct}&clientver={cv}&content=0",
56        root = api_root_for_client(client_type),
57        uid = user_id,
58        tok = token,
59        ct = client_type,
60        cv = client_ver_dotted,
61    );
62
63    let body = serde_json::json!({ "begin_id": begin_id });
64
65    let auth_headers = super::super::http_client::auth_http_default_headers(client_type)?;
66
67    // 单独设置 per-request 头:commconfig / AuthIPList 分支保留 C++ 的
68    // X-Futu-Client-Type / Version / Lang;业务 auth 请求不复用这套头。
69    let resp = http
70        .post(&url)
71        .headers(auth_headers)
72        .header("X-Futu-Client-Deviceid", device_id)
73        .header("X-Futu-Client-NNid", user_id.to_string())
74        .json(&body)
75        .send()
76        .await
77        .map_err(|e| FutuError::Codec(format!("commconfig POST failed: {e}")))?;
78
79    let status = resp.status();
80    let text = resp
81        .text()
82        .await
83        .map_err(|e| FutuError::Codec(format!("commconfig read body: {e}")))?;
84
85    if !status.is_success() {
86        return Err(FutuError::Codec(format!(
87            "commconfig HTTP {status}: {body}",
88            body = text.chars().take(200).collect::<String>()
89        )));
90    }
91
92    serde_json::from_str(&text).map_err(|e| {
93        FutuError::Codec(format!(
94            "commconfig JSON parse failed: {e} (body head: {head})",
95            head = text.chars().take(200).collect::<String>()
96        ))
97    })
98}
99
100/// `conf_info["forced_ip_for_conn"]` 解析 —— 对齐 C++
101/// `address.cpp:360-400` 的 `ParseForcedIpConfig()`。
102///
103/// 这个字段的值**和 `guaranteed_ip_for_conn` schema 不同**:
104/// - 外层是 object(不是直接 array):`{"forced_ip_for_conn": [...]}`
105/// - entry 字段:`{identity, ip, port, expire}` —— 单 IP + expire 时间戳
106/// - 未过期的 forced_ip **绕过**其他 fallback 直接用(最高优先级)
107///
108/// 和 `parse_guaranteed_ip` 一样支持三态(Null / Array-JSON-string / Object)。
109/// 多了一层 "object → forced_ip_for_conn" 的嵌套解包。
110pub fn parse_forced_ip(value: &serde_json::Value) -> ForcedIpMap {
111    let mut map: ForcedIpMap = HashMap::new();
112    if value.is_null() {
113        tracing::debug!("commconfig: forced_ip_for_conn is null");
114        return map;
115    }
116    // 取出 object 层:直接 object、或字符串装 object 两种都接受
117    let obj_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
118        if s.is_empty() {
119            return map;
120        }
121        match serde_json::from_str::<serde_json::Value>(s) {
122            Ok(v) => std::borrow::Cow::Owned(v),
123            Err(e) => {
124                tracing::warn!(
125                    error = %e,
126                    "commconfig: forced_ip_for_conn string-to-json parse failed"
127                );
128                return map;
129            }
130        }
131    } else {
132        std::borrow::Cow::Borrowed(value)
133    };
134    // 嵌套 unwrap:{ "forced_ip_for_conn": [...] } → array
135    let arr = obj_value
136        .as_object()
137        .and_then(|o| o.get("forced_ip_for_conn"))
138        .and_then(|v| v.as_array());
139    let Some(arr) = arr else {
140        tracing::warn!(
141            kind = value_kind(value),
142            "commconfig: forced_ip_for_conn missing nested `forced_ip_for_conn` array"
143        );
144        return map;
145    };
146
147    for entry in arr {
148        let Some(o) = entry.as_object() else {
149            continue;
150        };
151        let Some(identity) = json_u32_field(o, "identity", 0, "forced_ip_for_conn.identity") else {
152            continue;
153        };
154        let ip = o
155            .get("ip")
156            .and_then(|v| v.as_str())
157            .unwrap_or("")
158            .to_string();
159        let Some(port) = json_u16_field(o, "port", 9595, "forced_ip_for_conn.port") else {
160            continue;
161        };
162        let expire_ts = o.get("expire").and_then(|v| v.as_i64()).unwrap_or(0);
163
164        if ip.is_empty() {
165            continue;
166        }
167        let Some(attr) = UserAttribution::from_u32(identity) else {
168            tracing::debug!(
169                identity,
170                "commconfig: forced_ip skipping non-platform identity"
171            );
172            continue;
173        };
174        tracing::debug!(
175            identity,
176            ip = %ip,
177            port,
178            expire_ts,
179            "commconfig: forced_ip loaded"
180        );
181        map.insert(
182            attr,
183            ForcedIpEntry {
184                ip,
185                port,
186                expire_ts,
187            },
188        );
189    }
190    map
191}
192
193/// 诊断 log 用 —— 返回 `serde_json::Value` 的类型名字(Null/Bool/Number/
194/// String/Array/Object)。WARN 里带这个比 `{:?}` 更可读。
195pub fn value_kind(v: &serde_json::Value) -> &'static str {
196    match v {
197        serde_json::Value::Null => "Null",
198        serde_json::Value::Bool(_) => "Bool",
199        serde_json::Value::Number(_) => "Number",
200        serde_json::Value::String(_) => "String",
201        serde_json::Value::Array(_) => "Array",
202        serde_json::Value::Object(_) => "Object",
203    }
204}
205
206fn json_u32_field(
207    obj: &serde_json::Map<String, serde_json::Value>,
208    field: &'static str,
209    default: u32,
210    context: &'static str,
211) -> Option<u32> {
212    let Some(value) = obj.get(field) else {
213        return Some(default);
214    };
215    if let Some(raw) = value.as_i64()
216        && let Ok(parsed) = u32::try_from(raw)
217    {
218        return Some(parsed);
219    }
220    if let Some(raw) = value.as_u64()
221        && let Ok(parsed) = u32::try_from(raw)
222    {
223        return Some(parsed);
224    }
225    tracing::warn!(
226        context,
227        field,
228        kind = value_kind(value),
229        value = ?value,
230        "commconfig: skipping invalid u32 field"
231    );
232    None
233}
234
235fn json_u16_field(
236    obj: &serde_json::Map<String, serde_json::Value>,
237    field: &'static str,
238    default: u16,
239    context: &'static str,
240) -> Option<u16> {
241    let Some(value) = obj.get(field) else {
242        return Some(default);
243    };
244    if let Some(raw) = value.as_i64()
245        && let Ok(parsed) = u16::try_from(raw)
246    {
247        return Some(parsed);
248    }
249    if let Some(raw) = value.as_u64()
250        && let Ok(parsed) = u16::try_from(raw)
251    {
252        return Some(parsed);
253    }
254    tracing::warn!(
255        context,
256        field,
257        kind = value_kind(value),
258        value = ?value,
259        "commconfig: skipping invalid u16 field"
260    );
261    None
262}
263
264/// `conf_info["guaranteed_ip_for_conn"]` 的值可能是:
265/// 1. **JSON 字符串**(C++ `NNBiz_CommonConfig.cpp:141` + `toStyledString()`
266///    的典型来源,值是 `"[{...},{...}]"` 需要二次 parse)
267/// 2. **直接 array**(服务端没 stringify,直接嵌 JSON object)
268/// 3. `null` / 空串 / 缺字段(某些地区 / 账号状态,服务端没配置 guaranteed
269///    IP;正常,由调用方进入该通道自己的 fallback 链)
270///
271/// v1.4.21 前只支持 1,遇到 2/3 会打 `EOF while parsing a value` WARN 并
272/// 返回空 map。改成**同时支持三种形态**,只在明显的"格式错误"时才 WARN。
273///
274/// 对齐 C++ `ChannelAddressManager::ParseGuaranteedIpConfig()`
275/// (`address.cpp:302-358`) —— C++ 只处理字符串入口,我们更宽松。
276/// 返回 `(platform_map, broker_map, web_map)` —— Platform identity(1-6) 进 platform_map;
277/// C++ `channel.h:61-75` `kAllConnIdentity` 里列出的
278/// `CONN_BROKER_FUTU_*` (1001/1007/1008/1009/1012/1017/1019) 进 broker_map;
279/// `CONN_WEB_*` (10100..10107) 进 web_map;其他未知 identity 跳过。
280pub fn parse_guaranteed_ip(
281    value: &serde_json::Value,
282) -> (GuaranteedIpMap, GuaranteedBrokerIpMap, GuaranteedWebIpMap) {
283    let mut platform: GuaranteedIpMap = HashMap::new();
284    let mut broker: GuaranteedBrokerIpMap = HashMap::new();
285    let mut web: GuaranteedWebIpMap = HashMap::new();
286    // 空 / null → 没配置,安静返回(避免噪音 WARN 污染日志)
287    if value.is_null() {
288        tracing::debug!(
289            "commconfig: guaranteed_ip_for_conn is null (no guaranteed IPs for this account)"
290        );
291        return (platform, broker, web);
292    }
293    // 取出 array:直接是 array、或字符串里装 array 两种都接受
294    let arr_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
295        if s.is_empty() {
296            tracing::debug!("commconfig: guaranteed_ip_for_conn is empty string");
297            return (platform, broker, web);
298        }
299        match serde_json::from_str::<serde_json::Value>(s) {
300            Ok(v) => std::borrow::Cow::Owned(v),
301            Err(e) => {
302                tracing::warn!(
303                    error = %e,
304                    preview = %s.chars().take(80).collect::<String>(),
305                    "commconfig: guaranteed_ip_for_conn string-to-json parse failed"
306                );
307                return (platform, broker, web);
308            }
309        }
310    } else {
311        std::borrow::Cow::Borrowed(value)
312    };
313    let Some(arr) = arr_value.as_array() else {
314        tracing::warn!(
315            kind = ?value_kind(value),
316            "commconfig: guaranteed_ip_for_conn is neither array nor array-string"
317        );
318        return (platform, broker, web);
319    };
320
321    for entry in arr {
322        let Some(obj) = entry.as_object() else {
323            continue;
324        };
325        let Some(identity) = json_u32_field(obj, "identity", 0, "guaranteed_ip_for_conn.identity")
326        else {
327            continue;
328        };
329        let Some(port) = json_u16_field(obj, "port", 9595, "guaranteed_ip_for_conn.port") else {
330            continue;
331        };
332        let ips = obj.get("ip").and_then(|v| v.as_array());
333        let Some(ips) = ips else {
334            continue;
335        };
336
337        let mut pool: Vec<(String, u16)> = Vec::new();
338        for ip_v in ips {
339            if let Some(ip) = ip_v.as_str()
340                && !ip.is_empty()
341            {
342                pool.push((ip.to_string(), port));
343            }
344        }
345        if pool.is_empty() {
346            continue;
347        }
348
349        if let Some(attr) = UserAttribution::from_u32(identity) {
350            // Platform identity (1-6)
351            tracing::debug!(
352                identity,
353                port,
354                count = pool.len(),
355                "commconfig: platform guaranteed_ip loaded"
356            );
357            platform.insert(attr, pool);
358        } else if is_broker_identity(identity) {
359            // Broker identity (CONN_BROKER_FUTU_*)
360            tracing::debug!(
361                identity,
362                port,
363                count = pool.len(),
364                "commconfig: broker guaranteed_ip loaded"
365            );
366            broker.insert(identity, pool);
367        } else if is_web_identity(identity) {
368            // WebTCP-short identity (CONN_WEB_*)
369            tracing::debug!(
370                identity,
371                port,
372                count = pool.len(),
373                "commconfig: web guaranteed_ip loaded"
374            );
375            web.insert(identity, pool);
376        } else {
377            tracing::debug!(
378                identity,
379                "commconfig: skipping unknown guaranteed_ip identity"
380            );
381        }
382    }
383    (platform, broker, web)
384}
385
386/// 解析 C++ `web_tcp_config` 的全局 WebTCP-short identity。
387///
388/// 服务端可能把 `web_tcp_config` 作为 JSON 字符串或 object 下发。C++
389/// `WebRequestManager::UpdateCommConfig()` 只使用其中 `web_conn_identity`
390/// 来决定 WebTCP-short 目标 identity;它不是 broker 维度字段。
391pub fn parse_web_tcp_config_identity(value: &serde_json::Value) -> Option<u32> {
392    let obj_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
393        if s.is_empty() {
394            return None;
395        }
396        match serde_json::from_str::<serde_json::Value>(s) {
397            Ok(v) => std::borrow::Cow::Owned(v),
398            Err(e) => {
399                tracing::warn!(
400                    error = %e,
401                    preview = %s.chars().take(80).collect::<String>(),
402                    "commconfig: web_tcp_config string-to-json parse failed"
403                );
404                return None;
405            }
406        }
407    } else {
408        std::borrow::Cow::Borrowed(value)
409    };
410
411    let Some(obj) = obj_value.as_object() else {
412        tracing::debug!(
413            kind = value_kind(value),
414            "commconfig: web_tcp_config is not object/object-string"
415        );
416        return None;
417    };
418    let identity = json_u32_field(
419        obj,
420        "web_conn_identity",
421        0,
422        "web_tcp_config.web_conn_identity",
423    )?;
424    if is_web_identity(identity) {
425        Some(identity)
426    } else {
427        tracing::warn!(
428            identity,
429            "commconfig: ignoring invalid web_tcp_config.web_conn_identity"
430        );
431        None
432    }
433}
434
435/// 解析 C++ `auth_guaranteed_domain_list` 动态兜底域名表。
436///
437/// 服务端可能把它作为 JSON string 或 object 下发;key 是原始鉴权域名,
438/// value 是失败后用于 retry-domain 阶段的域名。
439pub fn parse_auth_guaranteed_domain_list(
440    value: &serde_json::Value,
441) -> (AuthGuaranteedDomainMap, bool) {
442    let mut out = AuthGuaranteedDomainMap::new();
443    if value.is_null() {
444        return (out, false);
445    }
446
447    let obj_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
448        if s.is_empty() {
449            return (out, false);
450        }
451        match serde_json::from_str::<serde_json::Value>(s) {
452            Ok(v) => std::borrow::Cow::Owned(v),
453            Err(e) => {
454                tracing::warn!(
455                    error = %e,
456                    preview = %s.chars().take(80).collect::<String>(),
457                    "commconfig: auth_guaranteed_domain_list string-to-json parse failed"
458                );
459                return (out, false);
460            }
461        }
462    } else {
463        std::borrow::Cow::Borrowed(value)
464    };
465
466    let Some(obj) = obj_value.as_object() else {
467        tracing::warn!(
468            kind = value_kind(value),
469            "commconfig: auth_guaranteed_domain_list is neither object nor object-string"
470        );
471        return (out, false);
472    };
473
474    for (domain, retry_domain) in obj {
475        let Some(retry_domain) = retry_domain.as_str() else {
476            continue;
477        };
478        if domain.is_empty() || retry_domain.is_empty() {
479            continue;
480        }
481        out.insert(domain.clone(), retry_domain.to_string());
482    }
483    (out, true)
484}
485
486/// 对齐 C++ `FTLogin/Src/ftlogin/channel/channel.h:61-75` `kAllConnIdentity`
487/// 里的 broker identity 集合。C++ enum 里有 `CONN_BROKER_AIR_STAR = 1022`,
488/// 但当前 `kAllConnIdentity` 没有列它,所以这里也不把 1022 当作 commconfig
489/// broker IP 池 identity。
490#[inline]
491pub fn is_broker_identity(identity: u32) -> bool {
492    matches!(identity, 1001 | 1007 | 1008 | 1009 | 1012 | 1017 | 1019)
493}
494
495/// 对齐 C++ `FTConnCmn.proto` 的 `CONN_WEB_*` identity。
496#[inline]
497pub fn is_web_identity(identity: u32) -> bool {
498    matches!(
499        identity,
500        CONN_WEB_CN
501            | CONN_WEB_US
502            | CONN_WEB_SG
503            | CONN_WEB_AU
504            | CONN_WEB_JP
505            | CONN_WEB_HK
506            | CONN_WEB_MY
507            | CONN_WEB_CA
508    )
509}