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