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