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}