Skip to main content

futu_opend/startup/
phase1.rs

1//! v1.4.110 Layer 3 A: startup Phase 1 — bootstrap前置 (logging / metrics /
2//! 守护设施). 抽自原 `mod.rs::run_daemon` 33..219 行段.
3//!
4//! Phase 1 副作用 (按顺序):
5//! 1. `--tz` 校验 + `TZ` env var set (必须在 tokio runtime 多线程化前)
6//! 2. keys-file 预 dry-run 验证 (REST/gRPC/WS) → fail-closed 早 abort
7//! 3. 初始化日志 (json vs plain + audit guard)
8//! 4. `tighten_secret_files_at_startup()` 把 0644 secret 文件收紧到 0600
9//! 5. 安装全局 panic hook (tracing + crash log + exit 101)
10//! 6. install futu_auth metrics registry
11//! 7. 构造 shared `RuntimeCounters`
12//! 8. 计算 `listen_addr` 并打印 "starting" 日志
13//! 9. WARN: moomoo + 显式 `--login-region`
14//! 10. 启动前端口冲突探测
15
16#![allow(unused_imports)]
17
18use anyhow::Result;
19use std::sync::Arc;
20
21use crate::cli::Platform;
22use crate::config::RuntimeConfig;
23use crate::crash_log::write_crash_log_file;
24
25/// Phase 1 output — 必须由 caller 持有到进程退出, 否则 audit guard drop
26/// 会让 tracing-appender 后台线程提早关闭丢事件.
27pub(super) struct Phase1Out {
28    /// audit 日志 guard, drop = tracing-appender 关闭. caller 必须 outlive.
29    pub(super) _audit_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
30    /// 共享 RuntimeCounters (REST + gRPC 共用一份保证跨 surface rate window 一致).
31    pub(super) shared_counters: Arc<futu_auth::RuntimeCounters>,
32    /// "ip:port" 字符串, 后续 server / WS / REST / gRPC / telnet 复用.
33    pub(super) listen_addr: String,
34    /// 从 config 复制的 keys file 路径 (Phase 4 使用).
35    pub(super) rest_keys_file: Option<std::path::PathBuf>,
36    pub(super) ws_keys_file: Option<std::path::PathBuf>,
37    pub(super) grpc_keys_file: Option<std::path::PathBuf>,
38    /// 是否允许无 auth 的 TCP listener (Phase 4 决策).
39    pub(super) allow_tcp_unauthenticated: bool,
40}
41
42pub(super) async fn run_phase1(config: &RuntimeConfig) -> Result<Phase1Out> {
43    // v1.4.87 #3 G1: --tz 覆盖. 必须在 tokio runtime 多线程起来之前应用,
44    // 否则 std::env::set_var 在多线程下 unsafe (Rust 2024 edition 要求).
45    // 实装: 设 TZ env var, chrono::Local 会重读.
46    //
47    // codex 0547 F6 (P3): tz 现走 config.tz (CLI 优先 + TOML fallback).
48    if let Some(tz) = &config.tz {
49        // 轻度校验 IANA name 格式 (不跑 chrono_tz parse 避免 dep 膨胀)
50        if tz.is_empty() || tz.contains(' ') || tz.contains('\n') {
51            eprintln!(
52                "error: --tz 无效 IANA timezone '{tz}'. 示例: Asia/Hong_Kong, America/New_York, UTC"
53            );
54            std::process::exit(2);
55        }
56        // SAFETY: main 入口, tokio runtime 的 worker threads 在 tokio::main
57        // 宏展开后生成, 在进入 async fn main body 时已经 ready. 我们可以
58        // 认为当前仍是单线程状态 (除非 panic hook 已触发多线程, 但 panic
59        // hook 也是 sync). Set TZ 后 chrono::Local 的 cached tz 会 re-read.
60        unsafe {
61            std::env::set_var("TZ", tz);
62        }
63        eprintln!("ℹ️  TZ set to '{tz}' via --tz flag / TOML tz (v1.4.87 #3 G1)");
64    }
65
66    // codex 0547 F6 (P3): 安全字段从 config 读 (而非 args.*) — TOML 也能
67    // override 这些 (与 docs "字段与 CLI 一致" 契约对齐).
68    let rest_keys_file = config.rest_keys_file.clone();
69    let ws_keys_file = config.ws_keys_file.clone();
70    let grpc_keys_file = config.grpc_keys_file.clone();
71    let audit_log = config.audit_log.clone();
72    let allow_tcp_unauthenticated = config.allow_tcp_unauthenticated;
73
74    // v1.4.104 eli P1-003 (P1) fix: keys-file 解析时序前移到 broker auth /
75    // SMS 之前. 之前 schema 错的 keys-file 要等到 surface server 启动时才报错
76    // (broker auth + SMS 之后 7+ 秒), 配置错应该启动就发现.
77    //
78    // 这里只**预解析 + dry-run 验证**, 不持久化结果 (实际 Arc 在 surface server
79    // 启动时由 KeyStore::load 重新读+解析, 因为 SIGHUP reload 也走那条路径).
80    // dry-run 失败 → 立即 abort, 不进 broker auth.
81    for (label, path_opt) in [
82        ("REST", &rest_keys_file),
83        ("gRPC", &grpc_keys_file),
84        ("WS", &ws_keys_file),
85    ] {
86        if let Some(path) = path_opt {
87            match futu_auth::KeyStore::load(path) {
88                Ok(ks) => {
89                    tracing::info!(
90                        surface = label,
91                        path = %path.display(),
92                        keys_loaded = ks.len(),
93                        "v1.4.104 eli P1-003 (P1): {} keys file pre-validated OK \
94                         (broker auth not yet started)",
95                        label
96                    );
97                }
98                Err(e) => {
99                    tracing::error!(
100                        surface = label,
101                        error = %e,
102                        path = %path.display(),
103                        "v1.4.104 eli P1-003 (P1): {} keys file pre-validation FAILED — \
104                         abort before broker auth / SMS to fail-closed early",
105                        label
106                    );
107                    return Err(anyhow::anyhow!(
108                        "v1.4.104 eli P1-003 (P1) fix: {} keys file at {} failed schema \
109                         validation: {e}. abort before broker auth / SMS. fix the keys \
110                         file then restart.",
111                        label,
112                        path.display()
113                    ));
114                }
115            }
116        }
117    }
118    // codex 0547 F6 (P3): merge_config 已提前到 args 仍可用阶段 (~line 1006);
119    // 此处不再重复 capture inject_auth_failure_every / merge_config.
120    // dev-flags 在 cfg(feature = "dev-flags") 路径上, capture 已在 args 解析后立即做.
121
122    // 1. 初始化日志(--log-level 参数生效,RUST_LOG 环境变量优先)
123    // audit 日志 guard 必须活到进程退出,否则 tracing-appender 后台线程可能丢事件。
124    let _audit_guard = if config.json_log {
125        // v1.4.27(BUG-7,加拿大同事 v1.4.26 回归测试发现):`--audit-log` 和
126        // `--json-log` 一起用时,之前是**静默忽略** `--audit-log`(只打 warn
127        // 到 stderr)、创建空文件;用户会误以为"没有审计事件发生"。现在改
128        // 硬失败 → 用户必须显式二选一,避免审计文件空导致的合规事故。
129        if audit_log.is_some() {
130            eprintln!(
131                "error: --audit-log and --json-log are mutually exclusive.\n\
132                 - --json-log: entire stderr as JSONL (full event stream)\n\
133                 - --audit-log: only target=futu_audit events as JSONL to a file\n\
134                 choose one. If you need both machine-readable stderr AND a separate audit \
135                 file, open an issue — today's layer composition doesn't support it."
136            );
137            std::process::exit(2);
138        }
139        futu_core::log::init_json_logging_with_level(&config.log_level);
140        None
141    } else {
142        match futu_core::log::init_logging_with_audit(&config.log_level, audit_log.as_deref()) {
143            Ok(guard) => {
144                if let (Some(path), Some(_)) = (audit_log.as_ref(), guard.as_ref()) {
145                    tracing::info!(
146                        path = %path.display(),
147                        "audit JSONL logger enabled (target=futu_audit → file)"
148                    );
149                }
150                guard
151            }
152            Err(e) => {
153                eprintln!("warning: failed to init audit log: {e}");
154                futu_core::log::init_logging_with_level(&config.log_level);
155                None
156            }
157        }
158    };
159
160    // v1.4.102 codex 27 F11 (P2) fix: startup chmod migration 移到无条件 path,
161    // 不再放在 login 分支里. 升级 (v1.4.101 及以前) 用户的
162    // `~/.futu-opend-rs/credentials-*.json` / `device-*.dat` 默认是 0644,
163    // 多用户机其他本地用户能读 tgtgt / web_sig. 此 fn 扫 ~/.futu-opend-rs/ 把
164    // secret 文件统一收紧到 0600.
165    //
166    // **历史**: BUG-012 修法 v1.4.102 ship 时把此 call 放在 `if let
167    // (Some(account), Some(password))` 分支内 — 无登录凭据 / 只跑 admin shell
168    // / 凭据解析失败的 daemon 都不执行 migration. codex 27 F11 audit 抓到.
169    //
170    // best-effort: chmod 失败 warn but don't fail (失败 != 凭据本身失效).
171    futu_backend::auth::tighten_secret_files_at_startup();
172
173    // v1.4.41 (P3.1 第二阶段): tracing subscriber 已装,重装 panic hook
174    // 让 panic 走 tracing::error!(audit log / JSON log / stderr 三出)。
175    std::panic::set_hook(Box::new(|info| {
176        let location = info
177            .location()
178            .map(|l| format!("{}:{}", l.file(), l.line()))
179            .unwrap_or_else(|| "<unknown>".to_string());
180        let payload = info
181            .payload()
182            .downcast_ref::<&str>()
183            .copied()
184            .or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
185            .unwrap_or("<non-string panic payload>");
186        let thread = std::thread::current()
187            .name()
188            .unwrap_or("<unnamed>")
189            .to_string();
190        tracing::error!(
191            target: "panic",
192            location = %location,
193            payload = %payload,
194            thread = %thread,
195            "PANIC caught by global hook"
196        );
197        eprintln!("PANIC at {location}: {payload} (thread={thread})");
198        // v1.4.97 P1-D-D: also write dated crash log to disk for forensics.
199        // Same as pre-tracing hook — covers panics that occur after tracing
200        // subscriber is up.
201        write_crash_log_file(info);
202        // v1.4.97 P1-D-E: propagate any panic → process exit so systemd
203        // Restart=on-failure can restart the daemon. Without this, tokio
204        // task panic silently kills only the task, leaving daemon zombie
205        // (REST/gRPC/WS/telnet/push tasks die one-by-one with main alive).
206        //
207        // Aligned with C++ NNCrashCenter `exit(NN_ExitCode_Crash)` pattern
208        // (NNCrashCenter_Mac.cpp:99; per agent 9 finding).
209        // `exit(101)` matches Rust panic default exit code; not used in
210        // test build (cfg(not(test))) to avoid disrupting unit tests.
211        #[cfg(not(test))]
212        std::process::exit(101);
213    }));
214
215    // 2. install 全局 metrics registry(让 audit::* 的 counter hook 和
216    //    REST `/metrics` 端点能对齐同一套计数器)
217    futu_auth::metrics::install(std::sync::Arc::new(futu_auth::MetricsRegistry::default()));
218
219    // 2.1 共享 RuntimeCounters:REST / gRPC 共用一个,这样 rate limit 和日累计
220    //     跨接口一致(同一把 key 通过 REST 下 3 单、gRPC 下 3 单,rate 窗口
221    //     看到 6 单,不是各看 3 单)
222    let shared_counters = std::sync::Arc::new(futu_auth::RuntimeCounters::new());
223
224    let listen_addr = format!("{}:{}", config.ip, config.port);
225    tracing::info!(addr = %listen_addr, "starting FutuOpenD Rust Gateway");
226
227    // v1.4.42 (eli v1.4.40 报告 P3.5 澄清): moomoo 账户 + 显式 --login-region
228    // → WARN 提示此 flag 对 moomoo 账户 noop(不影响 platform IP 选择)。
229    //
230    // 原因:login_region 只用在 CN 手机号账户的 salt URL `region_no` 参数,
231    // platform IP 池按 user_attribution(CN/HK/US/SG/AU/JP)从 conn_points
232    // 选,不按 login_region 切(3 个 region 代号 gz/sh/hk 是 CN 大陆分区,
233    // 和 platform IP 池没对应关系)。eli v1.4.40 报告观察 "三种 region 下
234    // platform IP 相同" 是预期行为,不是 bug。
235    //
236    // v1.4.40 计划中"加 WARN"实际没加(CHANGELOG 声称但代码漏),v1.4.42 补上。
237    if config.login_region_explicit && matches!(config.platform, Platform::Moomoo) {
238        tracing::warn!(
239            login_region = %config.login_region,
240            platform = "moomoo",
241            "--login-region={region} is a NO-OP for moomoo accounts — flag only \
242             applies to --platform futunn + CN phone-number login. moomoo accounts \
243             route via user_attribution automatically. Observed \"same platform IP \
244             across gz/sh/hk\" is expected behavior. Remove --login-region to silence.",
245            region = config.login_region
246        );
247    }
248
249    // v1.4.16:端口冲突检测——在登录之前先检查核心端口是否已被占用。
250    // 多实例并行(如同时跑 futunn + moomoo)是预期场景,但用户容易忘记改端口,
251    // 导致 futucli 连到了旧实例看到错账号的数据(同事 bug report #6)。
252    {
253        let ports_to_check: Vec<(&str, u16)> = std::iter::once(("FTAPI", config.port))
254            .chain(config.rest_port.map(|p| ("REST", p)))
255            .chain(config.grpc_port.map(|p| ("gRPC", p)))
256            .chain(config.websocket_port.map(|p| ("WebSocket", p)))
257            .collect();
258        for (name, port) in &ports_to_check {
259            let addr = std::net::SocketAddr::from(([127, 0, 0, 1], *port));
260            if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200))
261                .is_ok()
262            {
263                tracing::warn!(
264                    name,
265                    port,
266                    "⚠️  port {port} ({name}) is already in use! \
267                     Another futu-opend or other process may be running. \
268                     Use --port / --rest-port / --grpc-port to avoid conflict."
269                );
270            }
271        }
272    }
273
274    Ok(Phase1Out {
275        _audit_guard,
276        shared_counters,
277        listen_addr,
278        rest_keys_file,
279        ws_keys_file,
280        grpc_keys_file,
281        allow_tcp_unauthenticated,
282    })
283}