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)` 同时存在:
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. 无凭据 → 跑 offline mode (WARN)
14//! 5. `Arc::new(bridge)`
15//! 6. dev-flag `--inject-auth-failure-every` 注入 (cfg feature)
16
17#![allow(unused_imports)]
18
19use anyhow::Result;
20use std::sync::Arc;
21
22use futu_gateway_core::bridge::{GatewayBridge, GatewayConfig, PushEvent};
23
24use crate::cli::Platform;
25use crate::config::RuntimeConfig;
26use crate::credentials::resolve_login_password;
27use crate::hints;
28
29/// Phase 2 output — bridge (Arc-wrap 完成) + push_receiver (Some 时
30/// 表示登录成功并需要后续 push dispatcher) + `setup_only_done` (true 时
31/// orchestrator 应早退 Ok(())).
32pub(super) struct Phase2Out {
33    pub(super) bridge: Arc<GatewayBridge>,
34    pub(super) push_receiver: Option<tokio::sync::mpsc::Receiver<PushEvent>>,
35    pub(super) setup_only_done: bool,
36}
37
38pub(super) async fn run_phase2(
39    config: &RuntimeConfig,
40    listen_addr: &str,
41    _inject_auth_failure_every: Option<u64>,
42) -> Result<Phase2Out> {
43    // 2. 创建并初始化业务桥接层
44    let mut bridge = GatewayBridge::new();
45    let mut push_receiver = None;
46
47    // v1.4.18:7 层优先级密码解析。前面几条保留老行为兼容(老用户 --login-pwd
48    // 继续能用,但会打 WARN 推他们迁移),后面几条是新加的安全存储路径。
49    //
50    // 1. --login-pwd-file <path>   读文件(Docker secrets / systemd LoadCredential)
51    // 2. --login-pwd <plain>       明文 argv(WARN)—— 暴露在 ps aux / shell history
52    // 3. --login-pwd-md5 <hex>     md5 argv(WARN)—— 同样 argv 暴露(md5 等同明文)
53    // 4. FUTU_PWD env var          环境变量
54    // 5. OS keychain               `futucli set-login-pwd --account X` 写入的
55    // 6. 交互式 prompt(stdin 是 tty) 不回显、不进 shell history
56    // 7. 以上都没有 → 返回 None,上层按"无凭据"处理
57    // codex 0547 F2 (P2) fix: explicit `--login-pwd-file` 读失败 = fail-closed
58    // (Err propagated → daemon abort), 不再 silent fallback. `?` 让 explicit
59    // failure 立即终止 daemon. `unwrap_or((None, false))` 仅吃 7 层全无 / Ok(None).
60    let (password, password_is_md5) =
61        resolve_login_password(config.login_account.as_deref(), config)?.unwrap_or((None, false));
62
63    if let (Some(account), Some(password)) = (&config.login_account, &password) {
64        // v1.4.17:device_id 生命周期独立管理
65        //   1. `--reset-device` 先删除现有 device + credentials 文件
66        //   2. `read_or_generate_device_id` 从 ~/.futu-opend-rs/device-{hash}.dat
67        //      读;首次启动 / reset 后随机生成 16-hex 并持久化
68        //   3. `--device-id <hex>` 显式指定时覆盖文件值
69        if config.reset_device {
70            // v1.4.74 A3 BUG-003 fix(eli v1.4.71 AI tester §4.2 Layer 4):
71            // `--reset-device` 是**破坏性操作**(删凭证 + 删 device 文件,下次
72            // 必 SMS 验证),之前无二次确认。在交互终端下加 `(y/N)` prompt,
73            // 防止用户误触。非交互(systemd / Docker / CI)保持旧行为直接执行
74            // (无 tty 的 `read_line` 会返 empty string → 判 abort 不安全,
75            // 所以非 tty 场景跳过 prompt,by-design)。
76            let confirm = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
77                eprintln!();
78                eprintln!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
79                eprintln!("⚠️  --reset-device: 即将**删除**以下文件:");
80                eprintln!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
81                eprintln!("   ~/.futu-opend-rs/device-{{hash}}.dat       (device_id 持久化)");
82                eprintln!("   ~/.futu-opend-rs/credentials-{{hash}}.json (remember-login 凭证)");
83                eprintln!();
84                eprintln!("   执行后下次启动必须重新走 SMS 验证流程。");
85                eprintln!("   适用场景:device_id 被服务端锁定(ret_type=15/21 无法恢复)。");
86                eprintln!();
87                eprint!("   继续?(y/N) ");
88                let _ = std::io::Write::flush(&mut std::io::stderr());
89                let mut answer = String::new();
90                if std::io::stdin().read_line(&mut answer).is_err() {
91                    false
92                } else {
93                    matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
94                }
95            } else {
96                // 非交互(systemd / Docker / CI)—— 直接执行(没 tty 交互能力)
97                tracing::warn!(
98                    "⚠️  --reset-device running in non-interactive mode; skipping \
99                     confirmation prompt (proceeding with destructive reset)"
100                );
101                true
102            };
103
104            if confirm {
105                match futu_backend::auth::reset_device_state(account) {
106                    Ok(()) => tracing::info!(
107                        "⚠️  --reset-device: deleted device_id + credentials files, \
108                         will start fresh (SMS verification required)"
109                    ),
110                    Err(e) => {
111                        tracing::warn!(error = %e, "reset_device failed (non-fatal)")
112                    }
113                }
114            } else {
115                eprintln!();
116                eprintln!("已取消 --reset-device(未做任何修改)。");
117                eprintln!("若只想验证 device_id 当前值而不重置,请用:`ls ~/.futu-opend-rs/`");
118                tracing::info!("--reset-device aborted by user via interactive prompt");
119                std::process::exit(0);
120            }
121        }
122        // v1.4.102 codex 27 F11 (P2): tighten_secret_files_at_startup 已搬到
123        // main() 早期无条件执行 (见下方 ~ line 904 附近), 不再依赖 login 分支.
124        // 此处保留 placeholder 注释让 git history 看到 migration 历程.
125
126        let device_id =
127            futu_backend::auth::read_or_generate_device_id(account, config.device_id.as_deref());
128        tracing::info!(
129            account = %account,
130            device_id = %device_id,
131            platform = config.platform.name(),
132            auth_server = %config.auth_server,
133            "login credentials"
134        );
135        let app_lang = match config.lang.to_lowercase().as_str() {
136            "chs" => 0, // 简体中文
137            "cht" => 1, // 繁体中文
138            "en" => 2,  // 英文
139            _ => 0,     // 默认简体
140        };
141        let gw_config = GatewayConfig {
142            auth_server: config.auth_server.clone(),
143            account: account.clone(),
144            password: password.clone(),
145            password_is_md5,
146            region: config.login_region.clone(),
147            listen_addr: listen_addr.to_string(),
148            device_id,
149            app_lang,
150            // v1.4.15:moomoo 的 auth.moomoo.com 对 client-type=40 直接拒绝,
151            // 必须发 60(`NN_ClientType_FutuOpenDMooMoo`)。对齐 C++
152            // `FTGTW_Inner_API.cpp:491-492` 的 AppType → ClientType 映射。
153            client_type: match config.platform {
154                Platform::Futunn => 40,
155                Platform::Moomoo => 60,
156            },
157            setup_only: config.setup_only,
158        };
159
160        // v1.4.57 UX-04: 如果 --verify-code 传入了,构造一个一次性 callback
161        // 替代 stdin 交互(Telegram 中继场景等)。
162        let verify_cb: Option<futu_backend::auth::VerifyCodeCallback> = if let Some(code) =
163            config.verify_code.clone()
164        {
165            tracing::info!("v1.4.57 UX-04: using --verify-code for SMS input (no stdin prompt)");
166            let code_cell = std::sync::Arc::new(std::sync::Mutex::new(Some(code)));
167            let cb = move || -> Option<String> { code_cell.lock().ok().and_then(|mut g| g.take()) };
168            Some(Box::new(cb))
169        } else {
170            None
171        };
172
173        match bridge.initialize(&gw_config, verify_cb).await {
174            Ok(push_rx) => {
175                push_receiver = Some(push_rx);
176
177                // v1.4.17:--setup-only 完成认证 + 凭据缓存后直接退出
178                // (不启动任何 server)。用于 systemd / Docker 场景:先手动
179                // 前台跑一次完成 SMS 验证,生产启动时自动跳过 SMS。
180                if config.setup_only {
181                    tracing::info!(
182                        "✅ --setup-only: authentication succeeded and credentials cached. \
183                         Exiting. You can now start futu-opend in production (credentials \
184                         file at ~/.futu-opend-rs/ will be reused automatically)."
185                    );
186                    return Ok(Phase2Out {
187                        bridge: Arc::new(bridge),
188                        push_receiver: None,
189                        setup_only_done: true,
190                    });
191                }
192            }
193            Err(e) => {
194                // v1.4.17:识别 device_id 锁定类错误(21),给用户恢复建议
195                // v1.4.21:ret_type=15 不再简单归为 "device_id 锁定"——三类来源:
196                //   1. device_id 毒化(空 SMS 提交 / 长时间不用)→ --reset-device 可解
197                //   2. 服务端反刷限流(短时间连发 authority 请求)→ sleep 后重试
198                //   3. 账号级风控(账号状态异常,未在 App 激活等)→ 换 device_id 解不了
199                // 所以分别给 21 / 15 两种场景不同提示
200                hints::print_auth_error_hint(&e, config);
201                if config.setup_only {
202                    // setup 失败就退,不继续 offline mode
203                    return Err(anyhow::anyhow!("--setup-only: auth failed: {e}"));
204                }
205            }
206        }
207    } else {
208        if config.setup_only {
209            return Err(anyhow::anyhow!(
210                "--setup-only requires --login-account and --login-pwd"
211            ));
212        }
213        tracing::warn!("no login credentials provided, starting in offline mode");
214        tracing::warn!("use --login-account and --login-pwd to connect to backend");
215    }
216
217    // v1.4.32+ login 阶段结束,bridge 后续只需共享只读访问(register_handlers
218    // / start_push_dispatcher / admin snapshot 都是 &self)。封装成 Arc 方便
219    // admin_status_provider closure 长期持有一份。
220    let bridge = std::sync::Arc::new(bridge);
221
222    // v1.4.97 P1-D-C: dev-only auth failure injection (per CLAUDE.md pitfall
223    // #50 SPKI dev pattern — release build does NOT compile this branch).
224    // Tester real-machine verify P1-D ladder by combining:
225    //   FUTU_QOT_RELOGIN_BACKOFF_MS=5000,10000,20000,40000 \
226    //   futu-opend --inject-auth-failure-every=10 ...
227    // Expected log within ~75s: P1-D ladder cells 5s/10s/20s/40s each fire.
228    #[cfg(feature = "dev-flags")]
229    if let Some(inject_secs) = _inject_auth_failure_every {
230        if inject_secs == 0 {
231            tracing::warn!("v1.4.97 P1-D-C: --inject-auth-failure-every=0 ignored (must be > 0)");
232        } else {
233            tracing::warn!(
234                inject_secs,
235                "v1.4.97 P1-D-C: DEV-ONLY auth-failure injection ENABLED — \
236                 will clear login_cache every {}s to trigger P1-D self-heal \
237                 ladder. DO NOT USE IN PRODUCTION.",
238                inject_secs
239            );
240            let bridge_for_inject = std::sync::Arc::clone(&bridge);
241            tokio::spawn(async move {
242                let mut ticker = tokio::time::interval(std::time::Duration::from_secs(inject_secs));
243                ticker.tick().await; // skip first immediate
244                loop {
245                    ticker.tick().await;
246                    tracing::warn!("v1.4.97 P1-D-C: injecting qot_logined=false (DEV-ONLY)");
247                    bridge_for_inject.login_cache.clear();
248                }
249            });
250        }
251    }
252
253    Ok(Phase2Out {
254        bridge,
255        push_receiver,
256        setup_only_done: false,
257    })
258}