Skip to main content

futu_backend/
conn_ip.rs

1// 连接 IP 列表管理(platform CMD 1321 / broker CMD 20147)
2//
3// 对应 C++ f3clogin UpdateConnIpAfterLogin
4// 登录后发送 ConnIpReq,用于后续重连 / broker channel keepalive routing
5
6use futu_core::{
7    conn_ip::{
8        ConnIpAddressFacts, ConnIpSnapshotDecision, ConnIpSnapshotFacts,
9        ensure_conn_ip_backend_success, plan_conn_ip_snapshot_like_cpp,
10    },
11    error::Result,
12    log_redact::endpoint_log_fingerprint,
13};
14
15pub use futu_core::conn_ip::ConnIpSnapshot;
16
17mod store;
18pub use store::{
19    ConnIpCatalogStore, ConnIpCatalogStoreError, load_conn_ip_catalog, save_conn_ip_catalog,
20};
21
22use crate::auth::{UserAttribution, redact::uid_log_fingerprint};
23use crate::command_runtime::execute_connection_discovery;
24use crate::conn::BackendConn;
25use crate::proto_internal::ft_conn_ip;
26use futu_command_spec::{
27    CMD_CORE_PULL_CONN_IP as CMD_CONN_IP_PLATFORM,
28    CMD_FTLOGIN_UPDATE_CONN_IP_BROKER as CMD_CONN_IP_BROKER, ConnectionDiscoveryOperation,
29};
30
31/// 发送 CMD 1321 获取 IP 列表
32///
33/// 对应 C++ UpdateConnIpAfterLogin。`conn_identity` 字段**必须按
34/// user_attribution 派生**(`UserAttribution::to_conn_identity()`)——
35/// HK 账号 → 6,CN → 1,US → 2 等。v1.4.20 前硬编码成 1 → HK / US / SG /
36/// AU / JP 账号都会收到 `result_code=1 "conn_identify is invalid"`。
37/// v1.4.21 起按 attribution 动态设置,对齐 C++ F3CLogin Dart 示例里的
38/// `conn_identity: <N>`(`main.dart:383-386`)配置项。
39pub async fn fetch_conn_ip_list(
40    backend: &BackendConn,
41    user_id: u64,
42    device_id: &[u8],
43    attribution: UserAttribution,
44    client_ip: &str,
45) -> Result<Option<ConnIpSnapshot>> {
46    use prost::Message;
47    let conn_identity = attribution.to_conn_identity();
48    let req = build_conn_ip_req(user_id, device_id, conn_identity, Some(client_ip));
49
50    let body = req.encode_to_vec();
51    tracing::info!(
52        user_id_fp = %uid_log_fingerprint(user_id),
53        ?attribution,
54        conn_identity,
55        client_ip_present = !client_ip.is_empty(),
56        body_len = body.len(),
57        "sending CMD1321 ConnIpReq"
58    );
59
60    let resp = execute_connection_discovery(
61        backend,
62        ConnectionDiscoveryOperation::PlatformConnIp,
63        body.into(),
64    )
65    .await?;
66    parse_conn_ip_rsp(CMD_CONN_IP_PLATFORM, resp.body.as_ref(), conn_identity)
67}
68
69/// 发送 broker 通道的 UpdateConnIp 请求。
70///
71/// C++ `logger.cpp::OnRecvNormalLoginProtocol()` 在 broker `CMD1001` 登录成功、
72/// session key / sec_data 写入后调用 `UpdateConnIpAfterLogin()`,并用
73/// `kCmdUpdateConnIpBroker=20147` 发送同一个 `FTConnIP.ConnIpReq`。该命令在
74/// `FTChannelImpl::GetCmdConfigInfo()` 里是 no-encrypt/no-sec-data;Rust 的
75/// `nn_codec::should_skip_encryption(20147)` 必须同步保持 true。
76pub async fn send_broker_conn_ip_update(
77    backend: &BackendConn,
78    customer_id: u64,
79    device_id: &[u8],
80    conn_identity: u32,
81    client_ip: &str,
82) -> Result<Option<ConnIpSnapshot>> {
83    use prost::Message;
84
85    let req = build_conn_ip_req(customer_id, device_id, conn_identity, Some(client_ip));
86    let body = req.encode_to_vec();
87    tracing::info!(
88        customer_id_fp = %uid_log_fingerprint(customer_id),
89        conn_identity,
90        client_ip_present = !client_ip.is_empty(),
91        body_len = body.len(),
92        "sending CMD20147 broker ConnIpReq"
93    );
94
95    let resp = execute_connection_discovery(
96        backend,
97        ConnectionDiscoveryOperation::BrokerConnIp,
98        body.into(),
99    )
100    .await?;
101    parse_conn_ip_rsp(CMD_CONN_IP_BROKER, resp.body.as_ref(), conn_identity)
102}
103
104fn build_conn_ip_req(
105    user_id: u64,
106    device_id: &[u8],
107    conn_identity: u32,
108    client_ip: Option<&str>,
109) -> ft_conn_ip::ConnIpReq {
110    // C++ OpenD `NNLoginCenter::SetFTNetConfig()` writes the platform device model,
111    // Ethernet network type, and an empty carrier into F3C AppConfig. F3C then copies
112    // those exact facts in `FTLogger::UpdateConnIpAfterLogin()` (logger.cpp:2160-2185).
113    ft_conn_ip::ConnIpReq {
114        device_id: Some(device_id.to_vec()),
115        user_id: Some(user_id),
116        net_type: Some(3), // FTConnCmn::NET_TYPE_WIRE
117        conn_identity: Some(conn_identity),
118        client_feature: Some(ft_conn_ip::ClientFeature {
119            device_model: Some(crate::auth::device_type().to_string()),
120            net_type: Some("LAN".to_string()),
121            carrier: Some(String::new()),
122            // C++ `logger.cpp:1122-1125` always calls `set_client_ip(client_ip)`.
123            // Preserve field presence when login returned an empty string; some broker
124            // paths validate the echo shape, not just the visible value.
125            client_ip: client_ip.map(|ip| ip.to_string()),
126        }),
127    }
128}
129
130fn parse_conn_ip_rsp(
131    cmd_id: u16,
132    body: &[u8],
133    expected_conn_identity: u32,
134) -> Result<Option<ConnIpSnapshot>> {
135    use prost::Message;
136
137    let rsp: ft_conn_ip::ConnIpRsp = Message::decode(body)?;
138
139    if let Err(err) = ensure_conn_ip_backend_success(rsp.result_code) {
140        tracing::warn!(
141            cmd_id,
142            result_code = err.result_code(),
143            err_msg = ?rsp.err_msg,
144            "ConnIpRsp error"
145        );
146        return Ok(None);
147    }
148
149    let decision = plan_conn_ip_snapshot_like_cpp(ConnIpSnapshotFacts {
150        expected_conn_identity,
151        response_conn_identity: rsp.conn_identity,
152        addresses: rsp.ip_list.iter().map(conn_ip_address_facts).collect(),
153        anti_ddos_ip: rsp.anti_ddos_ip,
154        condition_flag: rsp.condition_flag,
155    });
156    match decision {
157        ConnIpSnapshotDecision::Store(snapshot) => {
158            tracing::info!(
159                cmd_id,
160                conn_identity = snapshot.conn_identity,
161                count = snapshot.addresses.len(),
162                anti_ddos_present = snapshot.anti_ddos_ip.is_some(),
163                condition_flag = snapshot.condition_flag,
164                "ConnIpRsp catalog accepted"
165            );
166            for (index, address) in snapshot.addresses.iter().enumerate() {
167                tracing::debug!(
168                    index,
169                    endpoint_fp = %endpoint_log_fingerprint(&address.endpoint()),
170                    region = address.region,
171                    english_description = %address.english_description,
172                    enable_backup_port = address.enable_backup_port,
173                    backup_port = address.backup_port,
174                    "ConnIP address"
175                );
176            }
177            Ok(Some(snapshot))
178        }
179        ConnIpSnapshotDecision::IgnoreIdentityMismatch { expected, actual } => {
180            tracing::warn!(cmd_id, expected, actual, "ConnIpRsp conn_identity mismatch");
181            Ok(None)
182        }
183        ConnIpSnapshotDecision::IgnoreEmptyCatalog => {
184            tracing::warn!(
185                cmd_id,
186                expected_conn_identity,
187                "ConnIpRsp empty catalog ignored"
188            );
189            Ok(None)
190        }
191    }
192}
193
194fn conn_ip_address_facts(item: &ft_conn_ip::ConnIpItem) -> ConnIpAddressFacts {
195    ConnIpAddressFacts {
196        ip: item.ip.clone(),
197        port: item.port,
198        region: item.region,
199        simplified_description: item.sc_desc.clone(),
200        traditional_description: item.tc_desc.clone(),
201        english_description: item.en_desc.clone(),
202        enable_backup_port: item.enable_backup_port,
203        backup_port: item.backup_port,
204    }
205}
206
207#[cfg(test)]
208mod store_tests;
209#[cfg(test)]
210mod tests;