Skip to main content

futu_backend/auth/commconfig/
accessors.rs

1//! auth/commconfig/accessors — forced_ip + ips_for_attribution / broker / web_identity
2//! + webtcp_hardcoded_addrs + identity helpers + lifecycle (spawn_refresher / delay_until_next_refresh)
3//!   (v1.4.110 CC Batch M: 拆自 commconfig.rs L871-1121)
4
5use std::sync::Arc;
6
7use crate::auth::UserAttribution;
8
9use super::types::CommonConfigSnapshot;
10
11use super::fetch::{SharedCommConfig, fetch_all};
12use super::parsers::is_web_identity;
13use super::types::{
14    CONN_WEB_AU, CONN_WEB_CA, CONN_WEB_CN, CONN_WEB_HK, CONN_WEB_JP, CONN_WEB_MY, CONN_WEB_SG,
15    CONN_WEB_US,
16};
17
18pub fn forced_ip_for_attribution(
19    snapshot: &CommonConfigSnapshot,
20    attr: UserAttribution,
21    now_ts: i64,
22) -> Option<(String, u16)> {
23    snapshot.forced_ip.get(&attr).and_then(|entry| {
24        if entry.is_valid(now_ts) {
25            Some((entry.ip.clone(), entry.port))
26        } else {
27            tracing::debug!(
28                ?attr,
29                expire_ts = entry.expire_ts,
30                now_ts,
31                "commconfig: forced_ip expired, ignored"
32            );
33            None
34        }
35    })
36}
37
38/// 从 snapshot 取某 attribution 对应的 IP 池(去拷贝成 `Vec<(String, u16)>`)。
39/// 如果没有则返回空。调用者可以把返回值 merge 到硬编码池最前面。
40pub fn ips_for_attribution(
41    snapshot: &CommonConfigSnapshot,
42    attr: UserAttribution,
43) -> Vec<(String, u16)> {
44    snapshot
45        .guaranteed_ip
46        .get(&attr)
47        .cloned()
48        .unwrap_or_default()
49}
50
51/// 从 snapshot 取 broker_id 对应的 broker 专用 IP 池。
52///
53/// broker 通道 CMD 1001 登录**必须连到正确的 broker IP**,否则服务端按
54/// customer_id 返的是该 IP 对应 broker 的账户列表(实测:broker 1001
55/// 连到 Platform US IP 时返回的是 broker 1007 账户)。
56///
57/// 空返回 → 降级到 Platform addr + 登录 redirect 兜底路径(v1.4.8 MVP
58/// 行为,不总靠得住)。
59pub fn ips_for_broker(snapshot: &CommonConfigSnapshot, broker_id: u32) -> Vec<(String, u16)> {
60    snapshot
61        .guaranteed_ip_broker
62        .get(&broker_id)
63        .cloned()
64        .unwrap_or_default()
65}
66
67/// 从 snapshot 取 WebTCP-short identity 对应的 IP 池。
68///
69/// broker_auth 的 C++ 主路径是 WebTCP-short(CMD 65507),目标 IP 来自
70/// commconfig `guaranteed_ip_for_conn` 的 10100..10107 identity。这个 helper
71/// 只返回服务端下发值;需要完整 fallback 链时用 `webtcp_addrs_for_identity`。
72pub fn ips_for_web_identity(snapshot: &CommonConfigSnapshot, identity: u32) -> Vec<(String, u16)> {
73    snapshot
74        .guaranteed_ip_web
75        .get(&identity)
76        .cloned()
77        .unwrap_or_default()
78}
79
80/// C++ `LoadHardcodeAddress()` 里 WebTCP-short identity 的本地保底池。
81///
82/// Ref:
83/// - `FTLogin/Src/ftlogin/channel/impl/address.cpp:616-653`
84/// - `FTLogin/Src/ftlogin/channel/address.h:12-18,70` (`ChannelAddress`
85///   默认主端口 443,构造参数里的 9595 是 backup port)
86/// - `FTLogin/Src/ftlogin/channel/impl/connector.cpp:228-233`(正常先连
87///   `Port()`,backup 分支才连 `BackupPort()`)
88///
89/// Rust 目前没有完整搬 C++ connector 的 backup-port 状态机。为了避免退化到
90/// 系统 DNS(外网环境容易被 reset),这里在 hardcoded fallback 时按
91/// "443 主端口 → 9595 备用端口" 的顺序展开同一组 IP。commconfig 下发池仍
92/// 原样优先,不追加 hardcoded。
93pub fn webtcp_hardcoded_addrs(identity: u32) -> &'static [(&'static str, u16)] {
94    match identity {
95        CONN_WEB_CN => &[
96            ("119.91.245.213", 443),
97            ("119.91.245.125", 443),
98            ("119.91.245.213", 9595),
99            ("119.91.245.125", 9595),
100        ],
101        CONN_WEB_HK => &[
102            ("101.32.198.103", 443),
103            ("43.135.64.109", 443),
104            ("101.32.198.103", 9595),
105            ("43.135.64.109", 9595),
106        ],
107        CONN_WEB_US => &[
108            ("49.51.78.82", 443),
109            ("170.106.62.85", 443),
110            ("49.51.78.82", 9595),
111            ("170.106.62.85", 9595),
112        ],
113        CONN_WEB_SG => &[
114            ("101.32.173.177", 443),
115            ("101.33.49.67", 443),
116            ("101.32.173.177", 9595),
117            ("101.33.49.67", 9595),
118        ],
119        CONN_WEB_AU => &[
120            ("54.206.243.201", 443),
121            ("3.104.68.90", 443),
122            ("54.206.243.201", 9595),
123            ("3.104.68.90", 9595),
124        ],
125        CONN_WEB_JP => &[
126            ("43.163.254.232", 443),
127            ("43.163.252.131", 443),
128            ("43.163.254.232", 9595),
129            ("43.163.252.131", 9595),
130        ],
131        CONN_WEB_MY => &[
132            ("47.254.245.70", 443),
133            ("47.254.254.140", 443),
134            ("47.254.245.70", 9595),
135            ("47.254.254.140", 9595),
136        ],
137        CONN_WEB_CA => &[
138            ("15.157.179.115", 443),
139            ("15.157.83.146", 443),
140            ("15.157.179.115", 9595),
141            ("15.157.83.146", 9595),
142        ],
143        _ => &[],
144    }
145}
146
147/// broker_auth WebTCP-short 的连接候选池:commconfig 优先,空时落到 C++
148/// hardcoded WebTCP 池。调用方不应自己拼 DNS 域名作为主路径。
149pub fn webtcp_addrs_for_identity(
150    snapshot: &CommonConfigSnapshot,
151    identity: u32,
152) -> Vec<(String, u16)> {
153    let dynamic = ips_for_web_identity(snapshot, identity);
154    if !dynamic.is_empty() {
155        return dynamic;
156    }
157    webtcp_hardcoded_addrs(identity)
158        .iter()
159        .map(|(ip, port)| ((*ip).to_string(), *port))
160        .collect()
161}
162
163/// C++ `WebRequestManager::UpdateCommConfig()` 的 WebTCP identity 兜底规则:
164/// 服务端未下发有效 `web_tcp_config.web_conn_identity` 时,moomoo app type
165/// 默认 `CONN_WEB_US`,其它(Futu OpenD/Futu HK)默认 `CONN_WEB_HK`。
166///
167/// `client_type=60` 是 moomoo OpenD;`client_type=40` 是 Futu OpenD。
168pub fn default_webtcp_identity_for_client_type(client_type: u8) -> u32 {
169    if client_type == 60 {
170        CONN_WEB_US
171    } else {
172        CONN_WEB_HK
173    }
174}
175
176/// broker_auth 的 WebTCP-short identity 归一入口。
177///
178/// 注意:这里故意不接收 `broker_id`。C++ broker auth 的请求域名按
179/// broker 变化,但 WebTCP-short 目标 identity 来自全局 `web_tcp_config`。
180/// 之前按 broker_id 把 1007 强行送到 `CONN_WEB_US`,在中国境内网络会
181/// 走 `www.moomoo.com` SNI/IP 池而失败;这与 FTLogin 不一致。
182pub fn broker_auth_webtcp_identity(snapshot: &CommonConfigSnapshot, client_type: u8) -> u32 {
183    snapshot
184        .web_conn_identity
185        .filter(|identity| is_web_identity(*identity))
186        .unwrap_or_else(|| default_webtcp_identity_for_client_type(client_type))
187}
188
189/// 计算下次刷新 delay 秒。输入 `next_refresh_ts`(0 表示未知),输出 clamp 后
190/// 的 sleep 时长。
191///
192/// - `next_ts > now + 7200` → 7200(最长 2h 刷一次)
193/// - `next_ts <= now` 或 0 → 1800(默认 30 分钟)
194/// - 其它 → 取 `next_ts - now`,最少 300(5 分钟,避免 busy loop)
195///
196/// 提出来方便单测。
197pub fn delay_until_next_refresh(next_refresh_ts: i64, now: i64) -> u64 {
198    const MIN_DELAY: u64 = 300; // 5 分钟最短
199    const MAX_DELAY: u64 = 7200; // 2 小时最长
200    const DEFAULT_DELAY: u64 = 1800; // 30 分钟兜底
201    if next_refresh_ts <= 0 {
202        return DEFAULT_DELAY;
203    }
204    let diff = next_refresh_ts - now;
205    if diff <= 0 {
206        return DEFAULT_DELAY;
207    }
208    (diff as u64).clamp(MIN_DELAY, MAX_DELAY)
209}
210
211/// 后台循环:按 `limit_time` 周期刷新 CommConfig,把新 snapshot 存到
212/// `SharedCommConfig`。
213///
214/// - 刷新成功 + 非空 → `store(new_snapshot)`
215/// - 刷新失败 / 返回空 → 保留旧 snapshot,打 WARN,下次继续试
216/// - 任务终身运行,依赖 opend 进程退出自然终止(不显式 shutdown —— 对齐
217///   `heartbeat::start_heartbeat` 和重连 task 的生命周期语义)
218///
219/// 首次 `store` 由 bridge 首登路径负责;本函数开始时就直接进入 sleep 循环。
220pub fn spawn_refresher(
221    snapshot: SharedCommConfig,
222    http: reqwest::Client,
223    client_type: u8,
224    device_id: String,
225    user_id: u64,
226    svr_time_offset: i64,
227) -> tokio::task::JoinHandle<()> {
228    tokio::spawn(async move {
229        loop {
230            let current = snapshot.load();
231            let now = chrono::Utc::now().timestamp();
232            let sleep_secs = delay_until_next_refresh(current.next_refresh_ts, now);
233            drop(current);
234
235            tracing::debug!(
236                sleep_secs,
237                "commconfig: refresher sleeping until next refresh"
238            );
239            tokio::time::sleep(std::time::Duration::from_secs(sleep_secs)).await;
240
241            tracing::info!("commconfig: refreshing...");
242            let new_snapshot =
243                fetch_all(&http, client_type, &device_id, user_id, svr_time_offset).await;
244            if new_snapshot.guaranteed_ip.is_empty()
245                && new_snapshot.guaranteed_ip_broker.is_empty()
246                && new_snapshot.guaranteed_ip_web.is_empty()
247            {
248                tracing::warn!(
249                    "commconfig: refresh returned empty guaranteed_ip maps, keeping previous snapshot"
250                );
251                // 失败不替换 snapshot,但也不 busy loop——下次 sleep 用旧的
252                // next_refresh_ts(通常已过期 → 走 DEFAULT_DELAY 30 分钟)
253                continue;
254            }
255            tracing::info!(
256                platform_pools = new_snapshot.guaranteed_ip.len(),
257                broker_pools = new_snapshot.guaranteed_ip_broker.len(),
258                web_pools = new_snapshot.guaranteed_ip_web.len(),
259                auth_retry_domains = new_snapshot.auth_guaranteed_domains.len(),
260                auth_retry_domain_configured = new_snapshot.auth_guaranteed_domains_configured,
261                next_refresh_ts = new_snapshot.next_refresh_ts,
262                "commconfig: snapshot refreshed"
263            );
264            snapshot.store(Arc::new(new_snapshot));
265        }
266    })
267}