Skip to main content

futu_opend/startup/
phase2.rs

1//! v1.4.110 Layer 3 A: startup Phase 2 — bridge 构造 + 登录 / SMS / setup-only
2//! / dev-flag 注入. 抽自原 `mod.rs::run_daemon` 220..475 行段.
3//!
4//! Phase 2 主要副作用 (按顺序):
5//! 1. `GatewayBridge::new()`, `push_receiver=None`
6//! 2. `resolve_login_password` 7-tier 解析
7//! 3. 如 `(account, password)` 同时存在,或 normal startup 下有 cached credentials:
8//!    - `--reset-device` 交互式确认 + `reset_device_state`
9//!    - `read_or_generate_device_id`
10//!    - 构造 `GatewayConfig` + verify_cb (优先 `--verify-code`)
11//!    - `bridge.initialize().await`
12//!    - Ok → setup_only 早退分支; Err → `hints::print_auth_error_hint`
13//! 4. 无账号 / 无密码且无 cached credentials → 跑 offline mode (WARN)
14//! 5. `Arc::new(bridge)`
15//! 6. dev-flag `--inject-auth-failure-every` 注入 (cfg feature)
16
17use anyhow::Result;
18use std::sync::Arc;
19
20use futu_domain_auth::{
21    AuthChallengeCommand, AuthChallengeKind, AuthChallengeRequestState, VerificationSecret,
22};
23use futu_gateway_core::bridge::{GatewayBridge, GatewayConfig, PushEvent};
24
25use crate::cli::Platform;
26use crate::config::{RuntimeConfig, app_lang_from_runtime_lang};
27use crate::credentials::resolve_login_password;
28use crate::hints;
29
30/// Phase 2 output — bridge (Arc-wrap 完成) + push_receiver (Some 时
31/// 表示登录成功并需要后续 push dispatcher) + `setup_only_done` (true 时
32/// orchestrator 应早退 Ok(()); 包括 setup-only 完成和用户交互式取消
33/// `--reset-device` 这类 CLI-only 早退).
34pub(super) struct Phase2Out {
35    pub(super) bridge: Arc<GatewayBridge>,
36    pub(super) auth_plan: Option<AuthPlan>,
37    pub(super) setup_only_done: bool,
38}
39
40pub(super) struct AuthPlan {
41    gateway_config: GatewayConfig,
42    verify_code: Option<String>,
43    verification_tty: bool,
44    interactive_retry: Option<InteractiveLoginPrompts>,
45    remember_login: bool,
46    allow_external_verification: bool,
47}
48
49impl AuthPlan {
50    pub(super) fn set_external_verification_available(&mut self, available: bool) {
51        self.allow_external_verification = available;
52    }
53}
54
55#[cfg(test)]
56mod tests;
57
58type VerifyCodePrompt = Arc<dyn Fn() -> Option<String> + Send + Sync>;
59
60struct AbortTaskOnDrop<T>(tokio::task::JoinHandle<T>);
61
62impl<T> Drop for AbortTaskOnDrop<T> {
63    fn drop(&mut self) {
64        self.0.abort();
65    }
66}
67
68fn build_verify_code_callback(
69    explicit_code: Option<String>,
70    stdin_is_terminal: bool,
71    interactive_prompt: VerifyCodePrompt,
72) -> Option<futu_backend::auth::VerifyCodeCallback> {
73    if let Some(code) = explicit_code {
74        tracing::info!("v1.4.57 UX-04: using --verify-code for SMS input (no stdin prompt)");
75        let code = Arc::new(code);
76        let callback = move || -> Option<String> { Some((*code).clone()) };
77        return Some(Box::new(callback));
78    }
79    if !stdin_is_terminal {
80        return None;
81    }
82
83    tracing::info!("interactive terminal available for pending SMS verification input");
84    Some(Box::new(move || {
85        interactive_prompt().and_then(|code| {
86            let code = code.trim();
87            (!code.is_empty()).then(|| code.to_string())
88        })
89    }))
90}
91
92fn prompt_sms_verification_code() -> Option<String> {
93    match rpassword::prompt_password("SMS verification code: ") {
94        Ok(code) => Some(code),
95        Err(error) => {
96            tracing::debug!(
97                error = %error,
98                "failed to read echo-disabled SMS verification code"
99            );
100            None
101        }
102    }
103}
104
105fn prompt_picture_verification_code() -> Option<String> {
106    match rpassword::prompt_password("Picture verification code (see PicVerifyCode.png): ") {
107        Ok(code) => Some(code),
108        Err(error) => {
109            tracing::debug!(
110                error = %error,
111                "failed to read echo-disabled picture verification code"
112            );
113            None
114        }
115    }
116}
117
118type AccountPrompt = Arc<dyn Fn() -> Option<String> + Send + Sync>;
119type PasswordPrompt = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
120type RememberPrompt = Arc<dyn Fn() -> Option<bool> + Send + Sync>;
121
122#[derive(Clone)]
123struct InteractiveLoginPrompts {
124    account: AccountPrompt,
125    password: PasswordPrompt,
126    remember: RememberPrompt,
127}
128
129struct InteractiveCredentials {
130    account: String,
131    password: String,
132    remember: bool,
133}
134
135impl InteractiveLoginPrompts {
136    fn production() -> Self {
137        Self {
138            account: Arc::new(prompt_login_account),
139            password: Arc::new(prompt_login_password),
140            remember: Arc::new(prompt_remember_login),
141        }
142    }
143
144    fn prompt_account(&self) -> Option<String> {
145        (self.account)()
146            .map(|account| account.trim().to_string())
147            .filter(|account| !account.is_empty())
148    }
149
150    fn prompt_credentials(&self) -> Option<InteractiveCredentials> {
151        let account = self.prompt_account()?;
152        let password = (self.password)(&account)?;
153        if password.is_empty() {
154            return None;
155        }
156        let remember = (self.remember)()?;
157        Some(InteractiveCredentials {
158            account,
159            password,
160            remember,
161        })
162    }
163}
164
165fn prompt_login_account() -> Option<String> {
166    eprint!("Login account: ");
167    if let Err(error) = std::io::Write::flush(&mut std::io::stderr()) {
168        tracing::debug!(error = %error, "failed to flush login-account prompt");
169        return None;
170    }
171    let mut account = String::new();
172    match std::io::stdin().read_line(&mut account) {
173        Ok(_) => Some(account),
174        Err(error) => {
175            tracing::debug!(error = %error, "failed to read login account");
176            None
177        }
178    }
179}
180
181fn prompt_login_password(account: &str) -> Option<String> {
182    match rpassword::prompt_password(format!("Login password for account {account}: ")) {
183        Ok(password) if !password.is_empty() => Some(password),
184        Ok(_) => None,
185        Err(error) => {
186            tracing::debug!(error = %error, "failed to read echo-disabled login password");
187            None
188        }
189    }
190}
191
192fn prompt_remember_login() -> Option<bool> {
193    eprint!("Remember login credentials? (y/N) ");
194    if let Err(error) = std::io::Write::flush(&mut std::io::stderr()) {
195        tracing::debug!(error = %error, "failed to flush remember-login prompt");
196        return None;
197    }
198    let mut answer = String::new();
199    match std::io::stdin().read_line(&mut answer) {
200        Ok(_) => Some(parse_remember_answer(&answer)),
201        Err(error) => {
202            tracing::debug!(error = %error, "failed to read remember-login answer");
203            None
204        }
205    }
206}
207
208fn parse_remember_answer(answer: &str) -> bool {
209    // Ref: C++ GTWLoginCenter.cpp:656-667 accepts Y/1 and treats every
210    // other value as cancel. The Rust prompt additionally accepts "yes" as
211    // the unambiguous long form while keeping empty input fail-closed.
212    matches!(
213        answer.trim().to_ascii_lowercase().as_str(),
214        "y" | "yes" | "1"
215    )
216}
217
218fn apply_interactive_credentials(
219    gateway_config: &mut GatewayConfig,
220    credentials: InteractiveCredentials,
221) -> futu_core::error::Result<bool> {
222    let device_id = futu_backend::auth::read_or_generate_device_id(&credentials.account, None)
223        .map_err(|error| {
224            futu_core::error::FutuError::Codec(format!(
225                "auth device store unavailable for interactive retry: {error}"
226            ))
227        })?;
228    gateway_config.account = credentials.account;
229    gateway_config.password = credentials.password;
230    gateway_config.password_is_md5 = false;
231    gateway_config.device_id = device_id;
232    Ok(credentials.remember)
233}
234
235fn enforce_remember_choice(
236    bridge: &GatewayBridge,
237    plan: &AuthPlan,
238) -> futu_core::error::Result<()> {
239    if plan.remember_login {
240        return Ok(());
241    }
242    if let Err(error) = futu_backend::auth::forget_cached_credentials(&plan.gateway_config.account)
243    {
244        let startup = bridge.startup_readiness().snapshot();
245        if startup.state == futu_server::identity::StartupState::Ready {
246            let _ = bridge.startup_readiness().transition(
247                startup.generation,
248                futu_server::identity::StartupEvent::RetryRequired,
249            );
250        }
251        return Err(futu_core::error::FutuError::Codec(format!(
252            "remember-login opt-out could not remove persisted credentials: {error}"
253        )));
254    }
255    Ok(())
256}
257
258async fn run_startup_challenge_client(
259    runtime: futu_gateway_core::bridge::auth_challenge::AuthChallengeRuntime,
260    mut snapshots: tokio::sync::watch::Receiver<Option<futu_domain_auth::AuthChallengeSnapshot>>,
261    verify_cb: Option<futu_backend::auth::VerifyCodeCallback>,
262    picture_prompt: Option<VerifyCodePrompt>,
263    allow_external_verification: bool,
264) -> futu_core::error::Result<()> {
265    let mut first_snapshot = true;
266    loop {
267        if first_snapshot {
268            first_snapshot = false;
269        } else {
270            snapshots.changed().await.map_err(|_| {
271                futu_core::error::FutuError::Codec(
272                    "auth challenge snapshot channel closed".to_string(),
273                )
274            })?;
275        }
276        let Some(snapshot) = snapshots.borrow_and_update().clone() else {
277            continue;
278        };
279        match snapshot.request_state {
280            AuthChallengeRequestState::Requested => {
281                let (generation, code) = match snapshot.kind {
282                    AuthChallengeKind::Picture => {
283                        let Some(prompt) = picture_prompt.as_deref() else {
284                            // No terminal owner: leave the actor pending so REST/MCP/CLI/TCP
285                            // Verification(1006) can own the same challenge.
286                            if allow_external_verification {
287                                continue;
288                            }
289                            let _ = runtime
290                                .submit(snapshot.generation, AuthChallengeCommand::Cancel)
291                                .await;
292                            return Err(futu_core::error::FutuError::ServerError {
293                                ret_type: 11,
294                                msg: "picture verification requires an interactive or public Verification owner"
295                                    .to_string(),
296                            });
297                        };
298                        let requested = runtime
299                            .submit(snapshot.generation, AuthChallengeCommand::RequestCode)
300                            .await?;
301                        (requested.generation, prompt())
302                    }
303                    AuthChallengeKind::DeviceSms
304                    | AuthChallengeKind::DeviceVoice
305                    | AuthChallengeKind::DeviceEmail => {
306                        let Some(callback) = verify_cb.as_deref() else {
307                            // Non-TTY startup deliberately stays restricted and waits for
308                            // the public Verification adapter instead of cancelling it.
309                            if allow_external_verification {
310                                continue;
311                            }
312                            let _ = runtime
313                                .submit(snapshot.generation, AuthChallengeCommand::Cancel)
314                                .await;
315                            return Err(futu_core::error::FutuError::SmsVerificationCodeRequired);
316                        };
317                        (snapshot.generation, callback())
318                    }
319                };
320                let Some(code) = code else {
321                    let _ = runtime
322                        .submit(generation, AuthChallengeCommand::Cancel)
323                        .await;
324                    return Err(futu_core::error::FutuError::ServerError {
325                        ret_type: -1,
326                        msg: "verification cancelled by user".to_string(),
327                    });
328                };
329                match runtime
330                    .submit(
331                        generation,
332                        AuthChallengeCommand::SubmitCode(VerificationSecret::new(code)),
333                    )
334                    .await
335                {
336                    Ok(authenticated)
337                        if authenticated.request_state
338                            == AuthChallengeRequestState::Authenticated =>
339                    {
340                        return Ok(());
341                    }
342                    Ok(_) => {}
343                    Err(error) => {
344                        let _ = runtime
345                            .submit(generation, AuthChallengeCommand::Cancel)
346                            .await;
347                        return Err(error);
348                    }
349                }
350            }
351            AuthChallengeRequestState::Authenticated => return Ok(()),
352            AuthChallengeRequestState::Cancelled | AuthChallengeRequestState::Failed => {
353                return Err(futu_core::error::FutuError::Codec(
354                    "auth challenge terminated".to_string(),
355                ));
356            }
357            AuthChallengeRequestState::Pending
358            | AuthChallengeRequestState::Submitted
359            | AuthChallengeRequestState::Replaced => {}
360        }
361    }
362}
363
364pub(super) async fn execute_auth_plan(
365    bridge: Arc<GatewayBridge>,
366    mut plan: AuthPlan,
367) -> futu_core::error::Result<tokio::sync::mpsc::Receiver<PushEvent>> {
368    loop {
369        let challenge_runtime = bridge.auth_challenge_runtime().clone();
370        let challenge_attempt = challenge_runtime.open_attempt()?;
371        let challenge_attempt_id = challenge_attempt.attempt_id();
372        let challenge_port: Arc<dyn futu_backend::auth::AuthChallengePort> =
373            Arc::new(challenge_attempt);
374        let verify_cb = build_verify_code_callback(
375            plan.verify_code.clone(),
376            plan.verification_tty,
377            Arc::new(prompt_sms_verification_code),
378        );
379        let challenge_snapshots = challenge_runtime.subscribe();
380        let mut challenge_client = AbortTaskOnDrop(tokio::spawn(run_startup_challenge_client(
381            challenge_runtime.clone(),
382            challenge_snapshots,
383            verify_cb,
384            plan.verification_tty
385                .then(|| Arc::new(prompt_picture_verification_code) as VerifyCodePrompt),
386            plan.allow_external_verification,
387        )));
388        let prepare_result = futu_backend::auth::with_auth_challenge_port(
389            challenge_port,
390            bridge.prepare_http_auth(&plan.gateway_config, None),
391        )
392        .await;
393        // The HTTP actor has returned. Close and drain this account attempt
394        // before success publication or an interactive prompt can create a
395        // new account attempt on the shared runtime.
396        challenge_runtime.close_attempt(challenge_attempt_id).await;
397        // HTTP auth/device verification may persist a final credential or a
398        // resumable DVS shell on both success and failure. Honor the operator's
399        // opt-out before every branch, including terminal/cancelled attempts.
400        enforce_remember_choice(bridge.as_ref(), &plan)?;
401        match prepare_result {
402            Ok(prepared) => {
403                challenge_client.0.abort();
404                let push_rx = match prepared {
405                    futu_gateway_core::bridge::Phase1Outcome::SetupOnlyDone(push_rx) => push_rx,
406                    futu_gateway_core::bridge::Phase1Outcome::Continue(prepared) => {
407                        // This phase publishes backend/cache state and spawns workers.
408                        // It is intentionally executed exactly once: failures here are
409                        // terminal for this bridge generation and must never re-prompt
410                        // into a second account on the same state owner.
411                        bridge
412                            .initialize_after_http_auth(&plan.gateway_config, *prepared)
413                            .await?
414                    }
415                };
416                return Ok(push_rx);
417            }
418            Err(mut error) => {
419                if let Ok(joined) = tokio::time::timeout(
420                    std::time::Duration::from_millis(100),
421                    &mut challenge_client.0,
422                )
423                .await
424                    && let Ok(Err(client_error)) = joined
425                {
426                    error = client_error;
427                } else {
428                    challenge_client.0.abort();
429                }
430
431                let Some(prompts) = plan.interactive_retry.as_ref() else {
432                    return Err(error);
433                };
434                let startup = bridge.startup_readiness().snapshot();
435                if startup.state == futu_server::identity::StartupState::Authenticating {
436                    bridge
437                        .startup_readiness()
438                        .transition(
439                            startup.generation,
440                            futu_server::identity::StartupEvent::AuthFailed,
441                        )
442                        .map_err(|transition| {
443                            futu_core::error::FutuError::Codec(format!(
444                                "interactive auth retry transition failed: {transition:?}"
445                            ))
446                        })?;
447                }
448                eprintln!(
449                    "Authentication failed. Re-enter the login account, or press Enter to stop."
450                );
451                let Some(credentials) = prompts.prompt_credentials() else {
452                    return Err(error);
453                };
454                plan.remember_login =
455                    apply_interactive_credentials(&mut plan.gateway_config, credentials)?;
456                let retry = bridge.startup_readiness().snapshot();
457                bridge
458                    .startup_readiness()
459                    .transition(
460                        retry.generation,
461                        futu_server::identity::StartupEvent::BeginAuthentication,
462                    )
463                    .map_err(|transition| {
464                        futu_core::error::FutuError::Codec(format!(
465                            "interactive auth retry begin failed: {transition:?}"
466                        ))
467                    })?;
468            }
469        }
470    }
471}
472
473pub(super) async fn run_phase2(
474    config: &RuntimeConfig,
475    listen_addr: &str,
476    _inject_auth_failure_every: Option<u64>,
477) -> Result<Phase2Out> {
478    // 2. 创建并初始化业务桥接层
479    let bridge = Arc::new(GatewayBridge::new_pending_auth());
480    let mut auth_plan = None;
481
482    // v1.4.18:7 层优先级密码解析。前面几条保留老行为兼容(老用户 --login-pwd
483    // 继续能用,但会打 WARN 推他们迁移),后面几条是新加的安全存储路径。
484    //
485    // 1. --login-pwd-file <path>   读文件(Docker secrets / systemd LoadCredential)
486    // 2. --login-pwd <plain>       明文 argv(WARN)—— 暴露在 ps aux / shell history
487    // 3. --login-pwd-md5 <hex>     md5 argv(WARN)—— 同样 argv 暴露(md5 等同明文)
488    // 4. FUTU_PWD env var          环境变量
489    // 5. OS credential store       兼容读取;macOS 发布包 writer 会 fail-closed
490    // 6. 交互式 prompt(stdin 是 tty) 不回显、不进 shell history
491    // 7. 以上都没有 → 返回 None。normal startup 会再看 setup-only 写入的
492    // cached credentials,允许 remember-login;两者都没有才按"无凭据"处理。
493    // codex 0547 F2 (P2) fix: explicit `--login-pwd-file` 读失败 = fail-closed
494    // (Err propagated → daemon abort), 不再 silent fallback. `?` 让 explicit
495    // failure 立即终止 daemon. `unwrap_or((None, false))` 仅吃 7 层全无 / Ok(None).
496    let stdin_is_terminal = std::io::IsTerminal::is_terminal(&std::io::stdin());
497    let interactive_login = stdin_is_terminal.then(InteractiveLoginPrompts::production);
498    let prompted_account = config.login_account.is_none();
499    let login_account = config.login_account.clone().or_else(|| {
500        interactive_login
501            .as_ref()
502            .and_then(InteractiveLoginPrompts::prompt_account)
503    });
504    let (password, password_is_md5) =
505        resolve_login_password(login_account.as_deref(), config)?.unwrap_or((None, false));
506    let remember_login = if prompted_account && password.is_some() {
507        interactive_login
508            .as_ref()
509            .and_then(|prompts| (prompts.remember)())
510            .unwrap_or(false)
511    } else {
512        true
513    };
514
515    let cached_credentials_available = login_account
516        .as_deref()
517        .is_some_and(|account| futu_backend::auth::credential_ticket_status(account).is_some());
518    let should_attempt_auth = should_attempt_startup_auth(
519        login_account.is_some(),
520        password.is_some(),
521        cached_credentials_available,
522        config.setup_only,
523        config.reset_device,
524    );
525
526    if should_attempt_auth && let Some(account) = &login_account {
527        let password = password.clone().unwrap_or_default();
528        // v1.4.17:device_id 生命周期独立管理
529        //   1. `--reset-device` 先删除现有 device + credentials 文件
530        //   2. `read_or_generate_device_id` 从 ~/.futu-opend-rs/device-{hash}.dat
531        //      读;首次启动 / reset 后随机生成 16-hex 并持久化
532        //   3. `--device-id <hex>` 显式指定时覆盖文件值
533        if config.reset_device {
534            // v1.4.74 A3 BUG-003 fix(external reviewer v1.4.71 AI tester §4.2 Layer 4):
535            // `--reset-device` 是**破坏性操作**(删凭证 + 删 device 文件,下次
536            // 必 SMS 验证),之前无二次确认。在交互终端下加 `(y/N)` prompt,
537            // 防止用户误触。非交互(systemd / Docker / CI)保持旧行为直接执行
538            // (无 tty 的 `read_line` 会返 empty string → 判 abort 不安全,
539            // 所以非 tty 场景跳过 prompt,by-design)。
540            let confirm = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
541                eprintln!();
542                eprintln!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
543                eprintln!("⚠️  --reset-device: 即将**删除**以下文件:");
544                eprintln!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
545                eprintln!("   ~/.futu-opend-rs/device-{{hash}}.dat       (device_id 持久化)");
546                eprintln!("   ~/.futu-opend-rs/credentials-{{hash}}.json (remember-login 凭证)");
547                eprintln!();
548                eprintln!("   执行后下次启动必须重新走 SMS 验证流程。");
549                eprintln!("   适用场景:device_id 被服务端锁定(ret_type=15/21 无法恢复)。");
550                eprintln!();
551                eprint!("   继续?(y/N) ");
552                if let Err(err) = std::io::Write::flush(&mut std::io::stderr()) {
553                    tracing::debug!(
554                        error = %err,
555                        "failed to flush reset-device confirmation prompt"
556                    );
557                }
558                let mut answer = String::new();
559                if std::io::stdin().read_line(&mut answer).is_err() {
560                    false
561                } else {
562                    matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
563                }
564            } else {
565                // 非交互(systemd / Docker / CI)—— 直接执行(没 tty 交互能力)
566                tracing::warn!(
567                    "⚠️  --reset-device running in non-interactive mode; skipping \
568                 confirmation prompt (proceeding with destructive reset)"
569                );
570                true
571            };
572
573            if confirm {
574                match futu_backend::auth::reset_device_state(account) {
575                    Ok(()) => tracing::info!(
576                        "⚠️  --reset-device: deleted device_id + credentials files, \
577                     will start fresh (SMS verification required)"
578                    ),
579                    Err(e) => {
580                        tracing::warn!(error = %e, "reset_device failed (non-fatal)")
581                    }
582                }
583            } else {
584                eprintln!();
585                eprintln!("已取消 --reset-device(未做任何修改)。");
586                eprintln!("若只想验证 device_id 当前值而不重置,请用:`ls ~/.futu-opend-rs/`");
587                tracing::info!("--reset-device aborted by user via interactive prompt");
588                return Ok(Phase2Out {
589                    bridge: Arc::clone(&bridge),
590                    auth_plan: None,
591                    setup_only_done: true,
592                });
593            }
594        }
595        // v1.4.102 codex 27 F11 (P2): tighten_secret_files_at_startup 已搬到
596        // main() 早期无条件执行 (见下方 ~ line 904 附近), 不再依赖 login 分支.
597        // 此处保留 placeholder 注释让 git history 看到 migration 历程.
598
599        let device_id =
600            futu_backend::auth::read_or_generate_device_id(account, config.device_id.as_deref())
601                .map_err(|err| {
602                    anyhow::anyhow!(
603                        "auth device store unavailable: {err}. \
604                 daemon needs a writable 0700 credentials directory before login"
605                    )
606                })?;
607        tracing::info!(
608            account_fp = %futu_backend::auth::redact::account_log_fingerprint(account),
609            device_id_fp = %futu_backend::auth::redact::device_id_log_fingerprint(&device_id),
610            platform = config.platform.name(),
611            auth_server = %config.auth_server,
612            "login credentials"
613        );
614        let app_lang = app_lang_from_runtime_lang(&config.lang);
615        let gw_config = GatewayConfig {
616            auth_server: config.auth_server.clone(),
617            account: account.clone(),
618            password,
619            password_is_md5,
620            region: config.login_region.clone(),
621            listen_addr: listen_addr.to_string(),
622            device_id,
623            app_lang,
624            // v1.4.15:moomoo 的 auth.moomoo.com 对 client-type=40 直接拒绝,
625            // 必须发 60(`NN_ClientType_FutuOpenDMooMoo`)。对齐 C++
626            // `FTGTW_Inner_API.cpp:491-492` 的 AppType → ClientType 映射。
627            client_type: match config.platform {
628                Platform::Futunn => 40,
629                Platform::Moomoo => 60,
630            },
631            setup_only: config.setup_only,
632            client_sig_proactive_refresh: config.client_sig_proactive_refresh,
633            client_sig_reactive_refresh: config.client_sig_reactive_refresh,
634            history_kline_cloud_sync: config.history_kline_cloud_sync,
635        };
636
637        // 输入源在 bridge.initialize 前一次确定:
638        // - 显式 --verify-code 保持最高优先级和 multi-shot;
639        // - 无显式值且 stdin 是 TTY 时构造 lazy、echo-disabled callback;
640        // - 非 TTY 无显式值时保持 None,让 cached-SMS admission 在任何
641        //   HTTP 前 fail closed,并给出显式恢复提示。
642        //
643        // v1.4.111 BUG-001 nearby regression fix (CLAUDE.md 坑 #55): 改为 **multi-shot**.
644        // 之前 `Arc<Mutex<Option<String>>>.take()` 是 one-shot, 第二次 cb() 返
645        // None. authenticate_with_callback 在以下 path 会两次调 cb:
646        //   1. remember_login 内部 code=20 → handle_device_verify(cb) → cb 消费第 1 次
647        //      → 该 handle_device_verify 任何 reason errored → 外层 fall through
648        //   2. Option B/A / password_auth fallback → handle_device_verify(cb) → cb 第 2 次
649        //      → 返 None → POST verify_device_code body 的 device_code="" → backend 拒
650        //      → ret_type=-1 "verification cancelled by user" 或 11
651        // 一次 daemon 启动用户只输一个 SMS 码; 整个 auth 流程内每次需要时返同一码即可
652        // (多次 POST 同 code 是安全的, backend 用 device_code_sig 防重放).
653        let plan = AuthPlan {
654            gateway_config: gw_config,
655            verify_code: config.verify_code.clone(),
656            verification_tty: stdin_is_terminal,
657            interactive_retry: interactive_login,
658            remember_login,
659            // Phase 4 computes this from listeners that actually bound plus
660            // their retained key-store capabilities. setup-only never reaches
661            // Phase 4 and therefore remains fail-loud without a TTY/code.
662            allow_external_verification: false,
663        };
664
665        if config.setup_only {
666            let remember_login = plan.remember_login;
667            match execute_auth_plan(Arc::clone(&bridge), plan).await {
668                Ok(_) => {
669                    if remember_login {
670                        tracing::info!(
671                            "✅ --setup-only: authentication succeeded and credentials cached. \
672                             Exiting. You can now start futu-opend in production."
673                        );
674                    } else {
675                        tracing::info!(
676                            "✅ --setup-only: authentication succeeded for this session; \
677                             remember-login was declined, so no credentials remain cached."
678                        );
679                    }
680                    return Ok(Phase2Out {
681                        bridge: Arc::clone(&bridge),
682                        auth_plan: None,
683                        setup_only_done: true,
684                    });
685                }
686                Err(error) => {
687                    hints::print_auth_error_hint(&error, config);
688                    return Err(startup_auth_failure_error(true, error));
689                }
690            }
691        }
692        auth_plan = Some(plan);
693    } else {
694        if config.setup_only {
695            return Err(anyhow::anyhow!(
696                "--setup-only requires --login-account and --login-pwd"
697            ));
698        }
699        tracing::warn!("no login credentials provided, starting in offline mode");
700        tracing::warn!("use --login-account and --login-pwd to connect to backend");
701        let pending = bridge.startup_readiness().snapshot();
702        let authenticating = bridge
703            .startup_readiness()
704            .transition(
705                pending.generation,
706                futu_server::identity::StartupEvent::BeginAuthentication,
707            )
708            .map_err(|error| anyhow::anyhow!("offline startup transition: {error:?}"))?;
709        bridge
710            .startup_readiness()
711            .transition(
712                authenticating.generation,
713                futu_server::identity::StartupEvent::Authenticated {
714                    user_id: 0,
715                    attribution: None,
716                },
717            )
718            .map_err(|error| anyhow::anyhow!("offline ready transition: {error:?}"))?;
719    }
720
721    // v1.4.97 P1-D-C: dev-only auth failure injection (per CLAUDE.md pitfall
722    // #50 SPKI dev pattern — release build does NOT compile this branch).
723    // Tester real-machine verify P1-D ladder by combining:
724    //   FUTU_QOT_RELOGIN_BACKOFF_MS=5000,10000,20000,40000 \
725    //   futu-opend --inject-auth-failure-every=10 ...
726    // Expected log within ~75s: P1-D ladder cells 5s/10s/20s/40s each fire.
727    #[cfg(feature = "dev-flags")]
728    if let Some(inject_secs) = _inject_auth_failure_every {
729        if inject_secs == 0 {
730            tracing::warn!("v1.4.97 P1-D-C: --inject-auth-failure-every=0 ignored (must be > 0)");
731        } else {
732            tracing::warn!(
733                inject_secs,
734                "v1.4.97 P1-D-C: DEV-ONLY auth-failure injection ENABLED — \
735             will clear login_cache every {}s to trigger P1-D self-heal \
736             ladder. DO NOT USE IN PRODUCTION.",
737                inject_secs
738            );
739            let bridge_for_inject = std::sync::Arc::clone(&bridge);
740            tokio::spawn(async move {
741                let mut ticker = tokio::time::interval(std::time::Duration::from_secs(inject_secs));
742                ticker.tick().await; // skip first immediate
743                loop {
744                    ticker.tick().await;
745                    tracing::warn!("v1.4.97 P1-D-C: injecting qot_logined=false (DEV-ONLY)");
746                    bridge_for_inject.caches().login_cache.clear();
747                }
748            });
749        }
750    }
751
752    Ok(Phase2Out {
753        bridge,
754        auth_plan,
755        setup_only_done: false,
756    })
757}
758
759fn startup_auth_failure_error(
760    setup_only: bool,
761    error: futu_core::error::FutuError,
762) -> anyhow::Error {
763    let error = anyhow::Error::new(error);
764    if setup_only {
765        error.context("--setup-only: auth failed")
766    } else {
767        error.context("startup authentication failed; public surfaces were not started")
768    }
769}
770
771fn should_attempt_startup_auth(
772    has_login_account: bool,
773    has_password: bool,
774    has_cached_credentials: bool,
775    setup_only: bool,
776    reset_device: bool,
777) -> bool {
778    has_login_account && (has_password || (!setup_only && !reset_device && has_cached_credentials))
779}