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}