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