futu_backend/auth/commconfig/
parsers.rs1use 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
26pub 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
34pub 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 let svr_ts = chrono::Utc::now().timestamp() + 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 resp = http
69 .post(&url)
70 .header("X-Futu-Client-Deviceid", device_id)
71 .header("X-Futu-Client-NNid", user_id.to_string())
72 .json(&body)
73 .send()
74 .await
75 .map_err(|e| FutuError::Codec(format!("commconfig POST failed: {e}")))?;
76
77 let status = resp.status();
78 let text = resp
79 .text()
80 .await
81 .map_err(|e| FutuError::Codec(format!("commconfig read body: {e}")))?;
82
83 if !status.is_success() {
84 return Err(FutuError::Codec(format!(
85 "commconfig HTTP {status}: {body}",
86 body = text.chars().take(200).collect::<String>()
87 )));
88 }
89
90 serde_json::from_str(&text).map_err(|e| {
91 FutuError::Codec(format!(
92 "commconfig JSON parse failed: {e} (body head: {head})",
93 head = text.chars().take(200).collect::<String>()
94 ))
95 })
96}
97
98pub fn parse_forced_ip(value: &serde_json::Value) -> ForcedIpMap {
109 let mut map: ForcedIpMap = HashMap::new();
110 if value.is_null() {
111 tracing::debug!("commconfig: forced_ip_for_conn is null");
112 return map;
113 }
114 let obj_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
116 if s.is_empty() {
117 return map;
118 }
119 match serde_json::from_str::<serde_json::Value>(s) {
120 Ok(v) => std::borrow::Cow::Owned(v),
121 Err(e) => {
122 tracing::warn!(
123 error = %e,
124 "commconfig: forced_ip_for_conn string-to-json parse failed"
125 );
126 return map;
127 }
128 }
129 } else {
130 std::borrow::Cow::Borrowed(value)
131 };
132 let arr = obj_value
134 .as_object()
135 .and_then(|o| o.get("forced_ip_for_conn"))
136 .and_then(|v| v.as_array());
137 let Some(arr) = arr else {
138 tracing::warn!(
139 kind = value_kind(value),
140 "commconfig: forced_ip_for_conn missing nested `forced_ip_for_conn` array"
141 );
142 return map;
143 };
144
145 for entry in arr {
146 let Some(o) = entry.as_object() else {
147 continue;
148 };
149 let identity = o.get("identity").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
150 let ip = o
151 .get("ip")
152 .and_then(|v| v.as_str())
153 .unwrap_or("")
154 .to_string();
155 let port = o.get("port").and_then(|v| v.as_i64()).unwrap_or(9595) as u16;
156 let expire_ts = o.get("expire").and_then(|v| v.as_i64()).unwrap_or(0);
157
158 if ip.is_empty() {
159 continue;
160 }
161 let Some(attr) = UserAttribution::from_u32(identity) else {
162 tracing::debug!(
163 identity,
164 "commconfig: forced_ip skipping non-platform identity"
165 );
166 continue;
167 };
168 tracing::debug!(
169 identity,
170 ip = %ip,
171 port,
172 expire_ts,
173 "commconfig: forced_ip loaded"
174 );
175 map.insert(
176 attr,
177 ForcedIpEntry {
178 ip,
179 port,
180 expire_ts,
181 },
182 );
183 }
184 map
185}
186
187pub fn value_kind(v: &serde_json::Value) -> &'static str {
190 match v {
191 serde_json::Value::Null => "Null",
192 serde_json::Value::Bool(_) => "Bool",
193 serde_json::Value::Number(_) => "Number",
194 serde_json::Value::String(_) => "String",
195 serde_json::Value::Array(_) => "Array",
196 serde_json::Value::Object(_) => "Object",
197 }
198}
199
200pub fn parse_guaranteed_ip(
216 value: &serde_json::Value,
217) -> (GuaranteedIpMap, GuaranteedBrokerIpMap, GuaranteedWebIpMap) {
218 let mut platform: GuaranteedIpMap = HashMap::new();
219 let mut broker: GuaranteedBrokerIpMap = HashMap::new();
220 let mut web: GuaranteedWebIpMap = HashMap::new();
221 if value.is_null() {
223 tracing::debug!(
224 "commconfig: guaranteed_ip_for_conn is null (no guaranteed IPs for this account)"
225 );
226 return (platform, broker, web);
227 }
228 let arr_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
230 if s.is_empty() {
231 tracing::debug!("commconfig: guaranteed_ip_for_conn is empty string");
232 return (platform, broker, web);
233 }
234 match serde_json::from_str::<serde_json::Value>(s) {
235 Ok(v) => std::borrow::Cow::Owned(v),
236 Err(e) => {
237 tracing::warn!(
238 error = %e,
239 preview = %s.chars().take(80).collect::<String>(),
240 "commconfig: guaranteed_ip_for_conn string-to-json parse failed"
241 );
242 return (platform, broker, web);
243 }
244 }
245 } else {
246 std::borrow::Cow::Borrowed(value)
247 };
248 let Some(arr) = arr_value.as_array() else {
249 tracing::warn!(
250 kind = ?value_kind(value),
251 "commconfig: guaranteed_ip_for_conn is neither array nor array-string"
252 );
253 return (platform, broker, web);
254 };
255
256 for entry in arr {
257 let Some(obj) = entry.as_object() else {
258 continue;
259 };
260 let identity = obj.get("identity").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
261 let port = obj.get("port").and_then(|v| v.as_i64()).unwrap_or(9595) as u16;
262 let ips = obj.get("ip").and_then(|v| v.as_array());
263 let Some(ips) = ips else {
264 continue;
265 };
266
267 let mut pool: Vec<(String, u16)> = Vec::new();
268 for ip_v in ips {
269 if let Some(ip) = ip_v.as_str()
270 && !ip.is_empty()
271 {
272 pool.push((ip.to_string(), port));
273 }
274 }
275 if pool.is_empty() {
276 continue;
277 }
278
279 if let Some(attr) = UserAttribution::from_u32(identity) {
280 tracing::debug!(
282 identity,
283 port,
284 count = pool.len(),
285 "commconfig: platform guaranteed_ip loaded"
286 );
287 platform.insert(attr, pool);
288 } else if is_broker_identity(identity) {
289 tracing::debug!(
291 identity,
292 port,
293 count = pool.len(),
294 "commconfig: broker guaranteed_ip loaded"
295 );
296 broker.insert(identity, pool);
297 } else if is_web_identity(identity) {
298 tracing::debug!(
300 identity,
301 port,
302 count = pool.len(),
303 "commconfig: web guaranteed_ip loaded"
304 );
305 web.insert(identity, pool);
306 } else {
307 tracing::debug!(
308 identity,
309 "commconfig: skipping unknown guaranteed_ip identity"
310 );
311 }
312 }
313 (platform, broker, web)
314}
315
316pub fn parse_web_tcp_config_identity(value: &serde_json::Value) -> Option<u32> {
322 let obj_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
323 if s.is_empty() {
324 return None;
325 }
326 match serde_json::from_str::<serde_json::Value>(s) {
327 Ok(v) => std::borrow::Cow::Owned(v),
328 Err(e) => {
329 tracing::warn!(
330 error = %e,
331 preview = %s.chars().take(80).collect::<String>(),
332 "commconfig: web_tcp_config string-to-json parse failed"
333 );
334 return None;
335 }
336 }
337 } else {
338 std::borrow::Cow::Borrowed(value)
339 };
340
341 let Some(obj) = obj_value.as_object() else {
342 tracing::debug!(
343 kind = value_kind(value),
344 "commconfig: web_tcp_config is not object/object-string"
345 );
346 return None;
347 };
348 let identity = obj
349 .get("web_conn_identity")
350 .and_then(|v| v.as_i64())
351 .unwrap_or(0) as u32;
352 if is_web_identity(identity) {
353 Some(identity)
354 } else {
355 tracing::warn!(
356 identity,
357 "commconfig: ignoring invalid web_tcp_config.web_conn_identity"
358 );
359 None
360 }
361}
362
363pub fn parse_auth_guaranteed_domain_list(
368 value: &serde_json::Value,
369) -> (AuthGuaranteedDomainMap, bool) {
370 let mut out = AuthGuaranteedDomainMap::new();
371 if value.is_null() {
372 return (out, false);
373 }
374
375 let obj_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
376 if s.is_empty() {
377 return (out, false);
378 }
379 match serde_json::from_str::<serde_json::Value>(s) {
380 Ok(v) => std::borrow::Cow::Owned(v),
381 Err(e) => {
382 tracing::warn!(
383 error = %e,
384 preview = %s.chars().take(80).collect::<String>(),
385 "commconfig: auth_guaranteed_domain_list string-to-json parse failed"
386 );
387 return (out, false);
388 }
389 }
390 } else {
391 std::borrow::Cow::Borrowed(value)
392 };
393
394 let Some(obj) = obj_value.as_object() else {
395 tracing::warn!(
396 kind = value_kind(value),
397 "commconfig: auth_guaranteed_domain_list is neither object nor object-string"
398 );
399 return (out, false);
400 };
401
402 for (domain, retry_domain) in obj {
403 let Some(retry_domain) = retry_domain.as_str() else {
404 continue;
405 };
406 if domain.is_empty() || retry_domain.is_empty() {
407 continue;
408 }
409 out.insert(domain.clone(), retry_domain.to_string());
410 }
411 (out, true)
412}
413
414#[inline]
417pub fn is_broker_identity(identity: u32) -> bool {
418 matches!(identity, 1001 | 1007 | 1008 | 1009 | 1012 | 1017 | 1019)
419}
420
421#[inline]
423pub fn is_web_identity(identity: u32) -> bool {
424 matches!(
425 identity,
426 CONN_WEB_CN
427 | CONN_WEB_US
428 | CONN_WEB_SG
429 | CONN_WEB_AU
430 | CONN_WEB_JP
431 | CONN_WEB_HK
432 | CONN_WEB_MY
433 | CONN_WEB_CA
434 )
435}