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 = 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 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
100pub 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 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 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
193pub 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
264pub 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 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 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 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 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 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
386pub 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
435pub 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#[inline]
491pub fn is_broker_identity(identity: u32) -> bool {
492 matches!(identity, 1001 | 1007 | 1008 | 1009 | 1012 | 1017 | 1019)
493}
494
495#[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}