Skip to main content

futu_backend/auth/
broker.rs

1//! Broker 通道鉴权
2//!
3//! 平台登录成功后,给每个已授权 broker(Futu HK/US/SG/AU/JP/MY/CA)发一个
4//! `POST https://{broker_auth_domain}/broker_auth/client_auth` 请求,换取
5//! `broker_client_sig` + `broker_client_key` + `customer_id`。这些 broker
6//! 级凭据是 broker TCP 通道 CMD 1001 登录的输入。
7//!
8//! 对齐 C++:
9//! - `FTLogin/Src/ftlogin/config/impl/broker_config.cpp:9-18`(broker_id 映射表)
10//! - `FTLogin/Src/ftlogin/config/impl/env_config.cpp:41-46`(7 个 broker 的 auth_domain)
11//! - `FTLogin/Src/ftlogin/config/impl/env_config.cpp:163-167`(HK auth_domain 本地替换)
12//! - `FTLogin/Src/ftlogin/auth/impl/auth_impl.cpp:2415-2422`(InitRequest 先走 GetReplacedDomain)
13//! - `FTLogin/Src/ftlogin/auth/impl/auth_impl.cpp:2439-2565`(HTTP/WebTCP 失败后走 retry domain / retry IP)
14//! - `FTLogin/Src/ftlogin/auth/impl/auth_ip_list.cpp:75-190,427-516`(broker auth retry IP 池)
15//! - `FTLogin/Src/ftlogin/auth/impl/auth_impl.cpp:640-674` `RefreshBrokerClientSig`
16//! - `FTLogin/Src/ftlogin/auth/impl/auth_impl.cpp:3378-3480` `ParseBrokerAuthResponse`
17
18use crate::conn::BackendProtocolIdentity;
19use futu_core::error::{FutuError, Result};
20pub(crate) use futu_domain_broker_reconnect::BrokerAuthStage;
21pub use futu_domain_broker_reconnect::{
22    BrokerAuth, BrokerConfig, broker_config, is_cpp_known_broker_id,
23};
24use futu_domain_broker_reconnect::{
25    BrokerAuthDomainPlan, BrokerAuthDomainPlanInput, BrokerAuthStartStageInput,
26    BrokerAuthStartStageLogAction, plan_broker_auth_start_stage,
27};
28
29use super::commconfig::AuthGuaranteedDomainMap;
30
31mod domain_attempt;
32mod failure;
33mod http;
34mod init_stage;
35mod request;
36mod response;
37mod retry_ip;
38mod retry_ip_attempt;
39mod retry_ip_candidates;
40mod route_cache;
41mod success_finalize;
42mod success_log;
43mod webtcp_attempt;
44mod workflow;
45pub(crate) use init_stage::broker_auth_init_stage_from_site_config;
46pub(crate) use request::broker_auth_request_body;
47pub(crate) use response::parse_broker_auth_response;
48#[cfg(test)]
49pub(crate) use retry_ip::broker_auth_retry_ip_candidates;
50pub(crate) use retry_ip::broker_auth_retry_ips;
51#[cfg(test)]
52pub(crate) use retry_ip::{
53    broker_auth_retry_ip_list_url, broker_auth_retry_ip_request_headers,
54    parse_broker_auth_retry_ip_snapshot,
55};
56pub use route_cache::BrokerAuthRouteCache;
57#[cfg(test)]
58pub(crate) use success_log::broker_auth_success_log_fields;
59use workflow::{BrokerAuthWorkflowInput, execute_broker_auth_workflow};
60
61/// broker auth 域名候选。首选 FTLogin 的 replaced domain;失败后才带上 C++
62/// `GetRetryDomain` 的 guaranteed domain。retry IP 阶段由调用方单独处理。
63///
64/// 证据:
65/// - `auth_impl.cpp:2491-2527`: WebTcp/HTTP 失败后依次重试 retry domain / retry IP
66/// - `auth_impl.cpp:2464-2487`: HK 本地兜底域名按 AppType 选 futunn / moomoo
67///
68/// 具体 FTLogin 替换/兜底策略由 `futu-domain-broker-reconnect` 的纯 planner 持有;
69/// backend 这里只把 commconfig map 适配为单个 broker 的 retry-domain fact。
70pub(crate) fn broker_auth_domain_plan(
71    cfg: BrokerConfig,
72    client_type: u8,
73    auth_guaranteed_domains: &AuthGuaranteedDomainMap,
74    auth_guaranteed_domains_configured: bool,
75) -> BrokerAuthDomainPlan {
76    futu_domain_broker_reconnect::plan_broker_auth_domains(BrokerAuthDomainPlanInput {
77        auth_domain: cfg.auth_domain,
78        client_type,
79        guaranteed_retry_domain: auth_guaranteed_domains
80            .get(cfg.auth_domain)
81            .map(String::as_str),
82        guaranteed_domains_configured: auth_guaranteed_domains_configured,
83    })
84}
85
86/// 向 broker auth 域名发 `/broker_auth/client_auth` POST 请求,换取
87/// `broker_client_sig` + `broker_client_key`。
88///
89/// 对齐 C++ `auth_impl.cpp:640-674`(`RefreshBrokerClientSig`)+
90/// `auth_impl.cpp:3378-3480`(`ParseBrokerAuthResponse`):
91/// - URL:`POST https://{broker_auth_domain}/broker_auth/client_auth`
92/// - Body:`{"uid", "auth_code", "device_id", "broker_id"}`
93/// - 响应 result 里的 `broker_client_key` 是 base64 编码 + AES-CBC-MD5 加密过的
94///
95/// ⚠️ 解密不是用 `rand_key`!对齐 `auth_impl.cpp:3434` —— 该处调用
96/// `DecryptByRandKey(&broker_client_key, nullptr)`,nullptr 触发
97/// `auth_cryptor.cpp:324-332` 分支:**用固定默认 key 解密**(先试 AES-256,
98/// 失败兜底 AES-128),**不是** Platform client_key 用的 rand_key。
99/// 具体 response/error/decrypt 逻辑由 sibling `broker::response` adapter 持有。
100pub struct BrokerAuthRequest<'a> {
101    pub http: &'a reqwest::Client,
102    pub protocol_identity: BackendProtocolIdentity,
103    pub uid: u64,
104    pub broker_id: u32,
105    pub auth_code: &'a str,
106    pub device_id: &'a str,
107    pub web_tcp_identity: u32,
108    pub web_tcp_addrs: &'a [(String, u16)],
109    pub site_config: Option<&'a super::site_config::SharedSiteConfig>,
110    pub auth_guaranteed_domains: &'a AuthGuaranteedDomainMap,
111    pub auth_guaranteed_domains_configured: bool,
112    pub route_cache: Option<&'a BrokerAuthRouteCache>,
113}
114
115pub async fn broker_auth(input: BrokerAuthRequest<'_>) -> Result<BrokerAuth> {
116    let BrokerAuthRequest {
117        http,
118        protocol_identity,
119        uid,
120        broker_id,
121        auth_code,
122        device_id,
123        web_tcp_identity,
124        web_tcp_addrs,
125        site_config,
126        auth_guaranteed_domains,
127        auth_guaranteed_domains_configured,
128        route_cache,
129    } = input;
130
131    let cfg = broker_config(broker_id).ok_or_else(|| {
132        FutuError::Codec(format!(
133            "broker_auth: unknown broker_id {broker_id} (not in broker_config map)"
134        ))
135    })?;
136
137    let body = broker_auth_request_body(uid, auth_code, device_id, broker_id);
138
139    let domain_plan = broker_auth_domain_plan(
140        cfg,
141        protocol_identity.client_type(),
142        auth_guaranteed_domains,
143        auth_guaranteed_domains_configured,
144    );
145    let primary_url = format!(
146        "https://{}/broker_auth/client_auth",
147        domain_plan.primary_domain
148    );
149    let start_stage_input =
150        match route_cache.and_then(|cache| cache.preferred_stage(cfg.auth_domain)) {
151            Some(stage) => BrokerAuthStartStageInput::CachedRoute(stage),
152            None => {
153                let site_config_stage = broker_auth_init_stage_from_site_config(
154                    web_tcp_identity,
155                    &primary_url,
156                    site_config,
157                )
158                .await;
159                BrokerAuthStartStageInput::SiteConfig(site_config_stage)
160            }
161        };
162    let start_stage_plan = plan_broker_auth_start_stage(start_stage_input, web_tcp_addrs.len());
163    let start_stage = start_stage_plan.start_stage;
164    let start_stage_source = start_stage_plan.source.as_str();
165    let transport_plan = start_stage_plan.transport_plan;
166    if start_stage_plan.log_action == BrokerAuthStartStageLogAction::SelectedNonWebTcp {
167        tracing::debug!(
168            broker_id,
169            original_domain = cfg.auth_domain,
170            stage = ?start_stage,
171            source = start_stage_source,
172            "broker_auth starting from selected FTLogin stage"
173        );
174    } else if start_stage_plan.log_action
175        == BrokerAuthStartStageLogAction::WebTcpSelectedWithoutAddrs
176    {
177        tracing::warn!(
178            broker_id,
179            original_domain = cfg.auth_domain,
180            web_identity = web_tcp_identity,
181            source = start_stage_source,
182            "broker_auth WebTCP-short selected but no WebTCP addresses are loaded; falling back to HTTP domain"
183        );
184    }
185    execute_broker_auth_workflow(BrokerAuthWorkflowInput {
186        http,
187        client_type: protocol_identity.client_type(),
188        protocol_identity,
189        uid,
190        broker_id,
191        cfg,
192        domain_plan,
193        transport_plan,
194        start_stage,
195        start_stage_source,
196        web_tcp_identity,
197        web_tcp_addrs,
198        route_cache,
199        primary_url: &primary_url,
200        body: &body,
201        device_id,
202    })
203    .await
204}