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