1use std::{
19 net::{IpAddr, SocketAddr},
20 sync::{Arc, Mutex},
21};
22
23use futu_core::error::{FutuError, Result};
24use futu_core::log_redact::endpoint_log_fingerprint;
25
26use super::auth_ip_list;
27use super::commconfig::AuthGuaranteedDomainMap;
28use super::redact::uid_log_fingerprint;
29
30mod retry_ip;
31use retry_ip::fetch_broker_auth_retry_ip_snapshot;
32pub(crate) use retry_ip::{broker_auth_retry_ip_candidates, broker_auth_retry_ips};
33#[cfg(test)]
34pub(crate) use retry_ip::{
35 broker_auth_retry_ip_list_url, broker_auth_retry_ip_request_headers,
36 parse_broker_auth_retry_ip_snapshot,
37};
38
39#[derive(Debug, Clone, Copy)]
46pub struct BrokerConfig {
47 pub broker_id: u32,
48 pub name: &'static str,
49 pub conn_identity: u32,
51 pub auth_domain: &'static str,
53}
54
55pub fn is_cpp_known_broker_id(broker_id: u32) -> bool {
61 matches!(
62 broker_id,
63 1001 | 1007 | 1008 | 1009 | 1012 | 1017 | 1019 | 1022
64 )
65}
66
67pub fn broker_config(broker_id: u32) -> Option<BrokerConfig> {
77 Some(match broker_id {
78 1001 => BrokerConfig {
79 broker_id: 1001,
80 name: "Futu HK",
81 conn_identity: 1001,
82 auth_domain: "authority.futuhk.com",
83 },
84 1007 => BrokerConfig {
85 broker_id: 1007,
86 name: "Futu US",
87 conn_identity: 1007,
88 auth_domain: "authority.us.moomoo.com",
89 },
90 1008 => BrokerConfig {
91 broker_id: 1008,
92 name: "Futu SG",
93 conn_identity: 1008,
94 auth_domain: "authority.sg.moomoo.com",
95 },
96 1009 => BrokerConfig {
97 broker_id: 1009,
98 name: "Futu AU",
99 conn_identity: 1009,
100 auth_domain: "authority.au.moomoo.com",
101 },
102 1012 => BrokerConfig {
103 broker_id: 1012,
104 name: "Futu JP",
105 conn_identity: 1012,
106 auth_domain: "authority.jp.moomoo.com",
107 },
108 1017 => BrokerConfig {
109 broker_id: 1017,
110 name: "Futu MY",
111 conn_identity: 1017,
112 auth_domain: "authority.my.moomoo.com",
113 },
114 1019 => BrokerConfig {
115 broker_id: 1019,
116 name: "Futu CA",
117 conn_identity: 1019,
118 auth_domain: "authority.ca.moomoo.com",
119 },
120 _ => return None,
121 })
122}
123
124#[derive(Debug, Clone)]
126pub struct BrokerAuth {
127 pub broker_id: u32,
128 pub customer_id: u64,
129 pub broker_client_sig: Vec<u8>,
130 pub broker_client_key: Vec<u8>,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum BrokerAuthStage {
140 WebTcp,
141 Http,
142 RetryDomain,
143 RetryIp,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub(crate) enum BrokerAuthWebTcpSkipReason {
148 StageNotWebTcp,
149 NoAddrs,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub(crate) struct BrokerAuthTransportPlan {
154 pub(crate) start_stage: BrokerAuthStage,
155 pub(crate) webtcp_attempted: bool,
156 pub(crate) webtcp_skip_reason: Option<BrokerAuthWebTcpSkipReason>,
157}
158
159pub(crate) fn broker_auth_transport_plan(
160 start_stage: BrokerAuthStage,
161 web_tcp_addr_count: usize,
162) -> BrokerAuthTransportPlan {
163 let webtcp_skip_reason = match (start_stage, web_tcp_addr_count) {
164 (BrokerAuthStage::WebTcp, 1..) => None,
165 (BrokerAuthStage::WebTcp, 0) => Some(BrokerAuthWebTcpSkipReason::NoAddrs),
166 _ => Some(BrokerAuthWebTcpSkipReason::StageNotWebTcp),
167 };
168 BrokerAuthTransportPlan {
169 start_stage,
170 webtcp_attempted: webtcp_skip_reason.is_none(),
171 webtcp_skip_reason,
172 }
173}
174
175#[derive(Debug, Default)]
176struct BrokerAuthRouteCacheInner {
177 last_request_original_domain: String,
178 last_success_stage: Option<BrokerAuthStage>,
179 last_success_retry_ip: Option<String>,
180}
181
182#[derive(Debug, Clone, Default)]
189pub struct BrokerAuthRouteCache {
190 inner: Arc<Mutex<BrokerAuthRouteCacheInner>>,
191}
192
193impl BrokerAuthRouteCache {
194 pub(crate) fn preferred_stage(&self, original_domain: &str) -> Option<BrokerAuthStage> {
195 let guard = self
196 .inner
197 .lock()
198 .unwrap_or_else(|poisoned| poisoned.into_inner());
199 (guard.last_request_original_domain == original_domain)
200 .then_some(guard.last_success_stage)
201 .flatten()
202 }
203
204 pub(crate) fn cached_retry_ip(&self, original_domain: &str) -> Option<String> {
205 let guard = self
206 .inner
207 .lock()
208 .unwrap_or_else(|poisoned| poisoned.into_inner());
209 (guard.last_request_original_domain == original_domain)
210 .then(|| guard.last_success_retry_ip.clone())
211 .flatten()
212 }
213
214 pub(crate) fn record_success(
215 &self,
216 original_domain: &str,
217 stage: BrokerAuthStage,
218 retry_ip: Option<String>,
219 ) {
220 let mut guard = self
221 .inner
222 .lock()
223 .unwrap_or_else(|poisoned| poisoned.into_inner());
224 guard.last_request_original_domain = original_domain.to_string();
225 guard.last_success_stage = Some(stage);
226 guard.last_success_retry_ip = retry_ip;
227 }
228}
229
230const AUTH_DEFAULT_KEY_32: &[u8] = b"5_B8tYqx^@aVJ6Vra2fi858@(5BGVYcJ";
235const AUTH_DEFAULT_KEY_16: &[u8] = b"@bsOj)h$ZHJx*TDI";
236
237fn broker_auth_replaced_domain(domain: &str) -> String {
248 match domain {
249 "authority.futuhk.com" => "authfthk.futuhn.com".to_string(),
250 _ => domain.to_string(),
251 }
252}
253
254pub(crate) fn broker_auth_domain_candidates(
262 cfg: BrokerConfig,
263 client_type: u8,
264 auth_guaranteed_domains: &AuthGuaranteedDomainMap,
265 auth_guaranteed_domains_configured: bool,
266) -> Vec<String> {
267 let mut domains = Vec::with_capacity(4);
268
269 let replaced = broker_auth_replaced_domain(cfg.auth_domain);
270 domains.push(replaced.clone());
271
272 if let Some(retry_domain) = auth_guaranteed_domains
273 .get(cfg.auth_domain)
274 .filter(|domain| !domain.is_empty())
275 {
276 domains.push(broker_auth_replaced_domain(retry_domain));
277 } else if !auth_guaranteed_domains_configured && cfg.auth_domain == "authority.futuhk.com" {
278 domains.push(if client_type == 60 {
279 "authfthk.moomoo.com".to_string()
280 } else {
281 "authfthk.futunn.com".to_string()
282 });
283 }
284
285 domains.dedup();
286 domains
287}
288
289enum BrokerAuthAttemptError {
290 Transport(String),
291 Json(String),
292}
293
294async fn post_broker_auth_json(
295 http: &reqwest::Client,
296 client_type: u8,
297 url: &str,
298 body: &serde_json::Value,
299 device_id: &str,
300) -> std::result::Result<serde_json::Value, BrokerAuthAttemptError> {
301 let headers = super::http_client::auth_business_headers(client_type, device_id)
302 .map_err(|e| BrokerAuthAttemptError::Json(format!("broker_auth headers: {e}")))?;
303 let response = http
304 .post(url)
305 .headers(headers)
306 .json(body)
307 .send()
308 .await
309 .map_err(|e| BrokerAuthAttemptError::Transport(e.to_string()))?;
310 response
311 .json::<serde_json::Value>()
312 .await
313 .map_err(|e| BrokerAuthAttemptError::Json(e.to_string()))
314}
315
316pub(crate) async fn broker_auth_init_stage_from_site_config(
317 web_tcp_identity: u32,
318 url: &str,
319 site_config: Option<&super::site_config::SharedSiteConfig>,
320) -> BrokerAuthStage {
321 let Some(site_config) = site_config else {
322 return BrokerAuthStage::WebTcp;
323 };
324 let parsed = match reqwest::Url::parse(url) {
325 Ok(parsed) => parsed,
326 Err(e) => {
327 tracing::warn!(url, error = %e, "broker_auth site_config URL parse failed; selecting HTTP");
328 return BrokerAuthStage::Http;
329 }
330 };
331 let Some(host) = parsed.host_str() else {
332 tracing::warn!(
333 url,
334 "broker_auth site_config URL has no host; selecting HTTP"
335 );
336 return BrokerAuthStage::Http;
337 };
338
339 let Some(config) = super::site_config::wait_latest(site_config).await else {
340 tracing::warn!(
341 web_identity = web_tcp_identity,
342 url,
343 "broker_auth site_config not loaded before C++ wait deadline; selecting HTTP"
344 );
345 return BrokerAuthStage::Http;
346 };
347
348 match config.query(web_tcp_identity, host, parsed.path()) {
349 super::site_config::WebChannelConfigType::Http => BrokerAuthStage::Http,
350 super::site_config::WebChannelConfigType::WebTcpShort
351 | super::site_config::WebChannelConfigType::WebTcpLong => BrokerAuthStage::WebTcp,
352 }
353}
354
355pub struct BrokerAuthRequest<'a> {
370 pub http: &'a reqwest::Client,
371 pub client_type: u8,
372 pub uid: u64,
373 pub broker_id: u32,
374 pub auth_code: &'a str,
375 pub device_id: &'a str,
376 pub web_tcp_identity: u32,
377 pub web_tcp_addrs: &'a [(String, u16)],
378 pub site_config: Option<&'a super::site_config::SharedSiteConfig>,
379 pub auth_guaranteed_domains: &'a AuthGuaranteedDomainMap,
380 pub auth_guaranteed_domains_configured: bool,
381 pub route_cache: Option<&'a BrokerAuthRouteCache>,
382}
383
384pub async fn broker_auth(input: BrokerAuthRequest<'_>) -> Result<BrokerAuth> {
385 let BrokerAuthRequest {
386 http,
387 client_type,
388 uid,
389 broker_id,
390 auth_code,
391 device_id,
392 web_tcp_identity,
393 web_tcp_addrs,
394 site_config,
395 auth_guaranteed_domains,
396 auth_guaranteed_domains_configured,
397 route_cache,
398 } = input;
399
400 let cfg = broker_config(broker_id).ok_or_else(|| {
401 FutuError::Codec(format!(
402 "broker_auth: unknown broker_id {broker_id} (not in broker_config map)"
403 ))
404 })?;
405
406 let body = serde_json::json!({
407 "uid": uid,
408 "auth_code": auth_code,
409 "device_id": device_id,
410 "broker_id": broker_id,
411 });
412
413 let domains = broker_auth_domain_candidates(
414 cfg,
415 client_type,
416 auth_guaranteed_domains,
417 auth_guaranteed_domains_configured,
418 );
419 let primary_domain = broker_auth_replaced_domain(cfg.auth_domain);
420 let primary_url = format!("https://{primary_domain}/broker_auth/client_auth");
421 let (start_stage, start_stage_source) = match route_cache
422 .and_then(|cache| cache.preferred_stage(cfg.auth_domain))
423 {
424 Some(stage) => (stage, "route_cache"),
425 None => (
426 broker_auth_init_stage_from_site_config(web_tcp_identity, &primary_url, site_config)
427 .await,
428 "site_config",
429 ),
430 };
431 let transport_plan = broker_auth_transport_plan(start_stage, web_tcp_addrs.len());
432 if start_stage != BrokerAuthStage::WebTcp {
433 tracing::debug!(
434 broker_id,
435 original_domain = cfg.auth_domain,
436 stage = ?start_stage,
437 source = start_stage_source,
438 "broker_auth starting from selected FTLogin stage"
439 );
440 } else if transport_plan.webtcp_skip_reason == Some(BrokerAuthWebTcpSkipReason::NoAddrs) {
441 tracing::warn!(
442 broker_id,
443 original_domain = cfg.auth_domain,
444 web_identity = web_tcp_identity,
445 source = start_stage_source,
446 "broker_auth WebTCP-short selected but no WebTCP addresses are loaded; falling back to HTTP domain"
447 );
448 }
449 let mut last_network_err: Option<String> = None;
450 let mut resp: Option<(serde_json::Value, BrokerAuthStage, Option<String>)> = None;
451
452 if transport_plan.webtcp_attempted {
457 tracing::debug!(
458 broker_id,
459 uid_fp = %uid_log_fingerprint(uid),
460 web_identity = web_tcp_identity,
461 addrs = web_tcp_addrs.len(),
462 url = %primary_url,
463 "POST /broker_auth/client_auth via WebTCP-short"
464 );
465 match super::webtcp::post_json_via_webtcp(
466 client_type,
467 web_tcp_identity,
468 web_tcp_addrs,
469 &primary_url,
470 &body,
471 device_id,
472 )
473 .await
474 {
475 Ok(value) => {
476 resp = Some((value, BrokerAuthStage::WebTcp, None));
477 }
478 Err(e) => {
479 let allows_http_fallback = e.allows_http_fallback();
480 last_network_err = Some(format!("webtcp identity {web_tcp_identity}: {e}"));
481 if allows_http_fallback {
482 tracing::warn!(
483 broker_id,
484 web_identity = web_tcp_identity,
485 addrs = web_tcp_addrs.len(),
486 error = %e,
487 "broker_auth WebTCP-short failed; falling back to HTTP domain"
488 );
489 } else {
490 tracing::warn!(
491 broker_id,
492 web_identity = web_tcp_identity,
493 addrs = web_tcp_addrs.len(),
494 error = %e,
495 "broker_auth WebTCP-short failed with no-fallback response"
496 );
497 return Err(e.into_futu_error());
498 }
499 }
500 }
501 }
502
503 let domain_start = match start_stage {
504 BrokerAuthStage::WebTcp | BrokerAuthStage::Http => 0,
505 BrokerAuthStage::RetryDomain => 1,
506 BrokerAuthStage::RetryIp => domains.len(),
507 };
508 for (idx, domain) in domains.iter().enumerate().skip(domain_start) {
509 if resp.is_some() {
510 break;
511 }
512 let stage = if idx == 0 {
513 BrokerAuthStage::Http
514 } else {
515 BrokerAuthStage::RetryDomain
516 };
517 let url = format!("https://{domain}/broker_auth/client_auth");
518 tracing::debug!(
519 broker_id,
520 uid_fp = %uid_log_fingerprint(uid),
521 url = %url,
522 original_domain = cfg.auth_domain,
523 stage = ?stage,
524 "POST /broker_auth/client_auth"
525 );
526
527 match post_broker_auth_json(http, client_type, &url, &body, device_id).await {
528 Ok(value) => {
529 resp = Some((value, stage, None));
530 }
531 Err(BrokerAuthAttemptError::Transport(e)) => {
532 last_network_err = Some(format!("{domain}: {e}"));
533 tracing::warn!(
534 broker_id,
535 domain,
536 error = %e,
537 "broker_auth transport failed; trying next domain if available"
538 );
539 continue;
540 }
541 Err(BrokerAuthAttemptError::Json(e)) => {
542 return Err(FutuError::Codec(format!(
543 "broker_auth json from {domain}: {e}"
544 )));
545 }
546 }
547 }
548
549 let retry_domain = broker_auth_replaced_domain(cfg.auth_domain);
550 let cached_retry_ip = if start_stage == BrokerAuthStage::RetryIp {
551 route_cache.and_then(|cache| cache.cached_retry_ip(cfg.auth_domain))
552 } else {
553 None
554 };
555 let dynamic_retry_ip_snapshot = if resp.is_none() && cached_retry_ip.is_none() {
556 fetch_broker_auth_retry_ip_snapshot(http, client_type).await
557 } else {
558 None
559 };
560 let retry_ip_candidates: Vec<String> = if let Some(ip) = cached_retry_ip {
561 vec![ip]
562 } else if let Some(snapshot) = dynamic_retry_ip_snapshot.as_ref() {
563 broker_auth_retry_ip_candidates(broker_id, Some(snapshot))
564 } else {
565 auth_ip_list::load_default_or_hardcoded_snapshot().broker_retry_ip_candidates(broker_id)
566 };
567 for ip in &retry_ip_candidates {
568 if resp.is_some() {
569 break;
570 }
571 let ip_addr = ip.parse::<IpAddr>().map_err(|e| {
572 FutuError::Codec(format!(
573 "invalid hardcoded broker_auth retry ip broker_id={broker_id} ip={ip}: {e}"
574 ))
575 })?;
576 let addr = SocketAddr::new(ip_addr, 443);
577 let ip_http = super::build_http_client_with_resolve(
578 client_type,
579 Some((retry_domain.as_str(), addr)),
580 )?;
581 let url = format!("https://{retry_domain}/broker_auth/client_auth");
582 tracing::debug!(
583 broker_id,
584 uid_fp = %uid_log_fingerprint(uid),
585 tls_domain = retry_domain,
586 target_ip_fp = %endpoint_log_fingerprint(ip),
587 "POST /broker_auth/client_auth via C++ retry IP"
588 );
589 match post_broker_auth_json(&ip_http, client_type, &url, &body, device_id).await {
590 Ok(value) => {
591 resp = Some((value, BrokerAuthStage::RetryIp, Some(ip.clone())));
592 break;
593 }
594 Err(BrokerAuthAttemptError::Transport(e)) => {
595 last_network_err = Some(format!(
596 "{retry_domain}@{}:443: {e}",
597 endpoint_log_fingerprint(ip)
598 ));
599 tracing::warn!(
600 broker_id,
601 tls_domain = retry_domain,
602 target_ip_fp = %endpoint_log_fingerprint(ip),
603 error = %e,
604 "broker_auth transport failed over retry IP; trying next IP/domain if available"
605 );
606 }
607 Err(BrokerAuthAttemptError::Json(e)) => {
608 return Err(FutuError::Codec(format!(
609 "broker_auth json from {retry_domain}@{}:443: {e}",
610 endpoint_log_fingerprint(ip)
611 )));
612 }
613 }
614 }
615
616 let retry_ip_fps: Vec<String> = retry_ip_candidates
617 .iter()
618 .map(|ip| endpoint_log_fingerprint(ip))
619 .collect();
620 let resp = resp.ok_or_else(|| {
621 FutuError::Network(std::io::Error::other(format!(
622 "broker_auth transport failed for broker_id={broker_id}; attempted_webtcp_addrs={web_tcp_addrs:?}; attempted domains={domains:?}; attempted retry_ip_fps={retry_ip_fps:?}; last_error={}",
623 last_network_err.unwrap_or_else(|| "none".to_string())
624 )))
625 })?;
626 let (resp, success_stage, success_retry_ip) = resp;
627 if let Some(cache) = route_cache {
628 cache.record_success(cfg.auth_domain, success_stage, success_retry_ip.clone());
629 }
630
631 if let Some(err) = resp.get("error").and_then(|e| e.as_object()) {
633 let code = err.get("error_code").and_then(|v| v.as_i64()).unwrap_or(-1);
634 let msg = err
635 .get("error_msg")
636 .and_then(|v| v.as_str())
637 .unwrap_or("unknown");
638 if code != 0 {
639 return Err(FutuError::ServerError {
640 ret_type: code as i32,
641 msg: format!("broker_auth broker_id={broker_id}: {msg}"),
642 });
643 }
644 }
645
646 let result = resp
647 .get("result")
648 .and_then(|r| r.as_object())
649 .ok_or_else(|| FutuError::Codec("broker_auth: missing result".into()))?;
650
651 let sig_b64 = result
652 .get("broker_client_sig")
653 .and_then(|v| v.as_str())
654 .ok_or_else(|| FutuError::Codec("broker_auth: missing broker_client_sig".into()))?;
655 let key_b64 = result
656 .get("broker_client_key")
657 .and_then(|v| v.as_str())
658 .ok_or_else(|| FutuError::Codec("broker_auth: missing broker_client_key".into()))?;
659 let customer_id = result.get("cid").and_then(|v| v.as_u64()).unwrap_or(0);
660 if customer_id == 0 {
661 return Err(FutuError::Codec(
662 "broker_auth: cid missing or zero in response".into(),
663 ));
664 }
665
666 use base64::Engine;
667 let broker_client_sig = base64::engine::general_purpose::STANDARD
668 .decode(sig_b64)
669 .map_err(|e| FutuError::Codec(format!("broker_client_sig decode: {e}")))?;
670 let ck_enc = base64::engine::general_purpose::STANDARD
671 .decode(key_b64)
672 .map_err(|e| FutuError::Codec(format!("broker_client_key decode: {e}")))?;
673
674 let broker_client_key =
677 match futu_net::encrypt::aes_cbc_md5_decrypt_var(AUTH_DEFAULT_KEY_32, &ck_enc) {
678 Ok(k) => {
679 tracing::debug!(
680 broker_id,
681 "broker_client_key decrypted with AUTH_DEFAULT_KEY_32 (AES-256)"
682 );
683 k
684 }
685 Err(e_256) => {
686 tracing::debug!(
687 broker_id,
688 error = %e_256,
689 "AES-256 default key failed, fallback to AES-128"
690 );
691 futu_net::encrypt::aes_cbc_md5_decrypt_var(AUTH_DEFAULT_KEY_16, &ck_enc).map_err(
692 |e_128| {
693 FutuError::Codec(format!(
694 "broker_client_key decrypt failed with both default keys: \
695 AES-256={e_256}, AES-128={e_128}"
696 ))
697 },
698 )?
699 }
700 };
701
702 tracing::info!(
703 broker_id,
704 broker = cfg.name,
705 customer_id_fp = %uid_log_fingerprint(customer_id),
706 success_stage = ?success_stage,
707 success_retry_ip_fp = success_retry_ip
708 .as_deref()
709 .map(endpoint_log_fingerprint)
710 .unwrap_or_else(|| "none".to_string()),
711 start_stage = ?transport_plan.start_stage,
712 start_stage_source,
713 webtcp_attempted = transport_plan.webtcp_attempted,
714 webtcp_skip_reason = ?transport_plan.webtcp_skip_reason,
715 webtcp_addrs = web_tcp_addrs.len(),
716 web_identity = web_tcp_identity,
717 client_sig_len = broker_client_sig.len(),
718 client_key_len = broker_client_key.len(),
719 "broker_auth success"
720 );
721 Ok(BrokerAuth {
722 broker_id,
723 customer_id,
724 broker_client_sig,
725 broker_client_key,
726 })
727}