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. keys-file 预 dry-run 验证 (REST/gRPC/WS) → fail-closed 早 abort
6//! 2. 初始化日志 (json vs plain + audit guard)
7//! 3. `tighten_secret_files_at_startup()` 把 0644 secret 文件收紧到 0600
8//! 4. 安装全局 panic hook (tracing + crash log + exit 101)
9//! 5. install futu_auth metrics registry
10//! 6. 构造 shared `RuntimeCounters`
11//! 7. 计算 `listen_addr` 并打印 "starting" 日志
12//! 8. WARN: moomoo + 显式 `--login-region`
13//! 9. 启动前端口冲突探测
14//!
15//! `--tz` / TOML `tz` 必须在 Tokio runtime 创建前应用,由
16//! [`super::apply_pre_runtime_tz`] 在 sync `main()` 里调用。
17
18use anyhow::Result;
19use std::path::PathBuf;
20use std::sync::Arc;
21
22use crate::cli::Platform;
23use crate::config::RuntimeConfig;
24use crate::crash_log::write_crash_log_file;
25use futu_core::localization::{
26    LanguagePackAutoUpdateOptions, auto_update_language_pack, default_language_pack_cache_root,
27};
28
29/// Phase 1 output — 必须由 caller 持有到进程退出, 否则 audit guard drop
30/// 会让 tracing-appender 后台线程提早关闭丢事件.
31pub(super) struct Phase1Out {
32    /// audit 日志 guard, drop = tracing-appender 关闭. caller 必须 outlive.
33    pub(super) _audit_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
34    /// 共享 RuntimeCounters (REST + gRPC 共用一份保证跨 surface rate window 一致).
35    pub(super) shared_counters: Arc<futu_auth::RuntimeCounters>,
36    /// "ip:port" 字符串, 后续 server / WS / REST / gRPC / telnet 复用.
37    pub(super) listen_addr: String,
38    /// 从 config 复制的 keys file 路径 (Phase 4 使用).
39    pub(super) rest_keys_file: Option<std::path::PathBuf>,
40    pub(super) ws_keys_file: Option<std::path::PathBuf>,
41    pub(super) grpc_keys_file: Option<std::path::PathBuf>,
42    /// 是否允许无 auth 的 TCP listener (Phase 4 决策).
43    pub(super) allow_tcp_unauthenticated: bool,
44}
45
46pub(super) fn is_valid_iana_tz_name(tz: &str) -> bool {
47    if tz == "UTC" {
48        return true;
49    }
50    // Local shape guard for `--tz` before writing the process-wide TZ env var.
51    // We intentionally avoid a chrono_tz parse dependency here (see v1.4.87
52    // --tz decision) and accept only IANA-style slash-separated ASCII names.
53    // The 128-byte cap is a defensive config-boundary limit, not a protocol
54    // value; revisit if IANA tzdb ever grows names beyond this shape.
55    if tz.is_empty() || tz.len() > 128 || tz.starts_with('/') || tz.ends_with('/') {
56        return false;
57    }
58
59    let mut parts = 0usize;
60    for part in tz.split('/') {
61        parts += 1;
62        if part.is_empty() || part == "." || part == ".." {
63            return false;
64        }
65        if part.contains('.') {
66            return false;
67        }
68        if !part
69            .bytes()
70            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'+'))
71        {
72            return false;
73        }
74    }
75
76    parts >= 2
77}
78
79pub(super) fn apply_pre_runtime_tz(config: &RuntimeConfig) {
80    if let Some(tz) = &config.tz {
81        // 轻度校验 IANA name 格式 (不跑 chrono_tz parse 避免 dep 膨胀)
82        if !is_valid_iana_tz_name(tz) {
83            eprintln!(
84                "error: --tz 无效 IANA timezone '{tz}'. 示例: Asia/Hong_Kong, America/New_York, UTC"
85            );
86            std::process::exit(2);
87        }
88        // SAFETY: production main calls this before constructing the Tokio
89        // runtime, so no runtime worker thread can concurrently read the
90        // process environment. Tests only cover the shape validator; they do
91        // not call this helper concurrently.
92        unsafe {
93            std::env::set_var("TZ", tz);
94        }
95        eprintln!("ℹ️  TZ set to '{tz}' via --tz flag / TOML tz (v1.4.87 #3 G1)");
96    }
97}
98
99fn language_pack_cache_root(config: &RuntimeConfig) -> PathBuf {
100    config
101        .language_pack_cache_dir
102        .clone()
103        .unwrap_or_else(default_language_pack_cache_root)
104}
105
106pub(super) fn language_pack_auto_update_options(
107    config: &RuntimeConfig,
108) -> LanguagePackAutoUpdateOptions {
109    LanguagePackAutoUpdateOptions {
110        enabled: config.language_pack_auto_update,
111        endpoint: config.language_pack_endpoint.clone(),
112        cache_root: language_pack_cache_root(config),
113        timeout_ms: config.language_pack_update_timeout_ms,
114        lang_filter: None,
115    }
116}
117
118fn spawn_language_pack_auto_update(config: &RuntimeConfig) {
119    let options = language_pack_auto_update_options(config);
120    tokio::spawn(async move {
121        let outcome = auto_update_language_pack(options).await;
122        let status = outcome.state.as_str();
123        match outcome.error.as_deref() {
124            Some(error) => tracing::warn!(
125                target = "language_pack",
126                status,
127                error,
128                pack_version = outcome.pack_version.as_deref().unwrap_or("-"),
129                "language_pack_auto_update"
130            ),
131            None => tracing::info!(
132                target = "language_pack",
133                status,
134                pack_version = outcome.pack_version.as_deref().unwrap_or("-"),
135                "language_pack_auto_update"
136            ),
137        }
138    });
139}
140
141fn port_probe_addr(bind_ip: &str, port: u16) -> std::net::SocketAddr {
142    match bind_ip.parse::<std::net::IpAddr>() {
143        Ok(std::net::IpAddr::V4(ip)) if ip.is_unspecified() => {
144            std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port))
145        }
146        Ok(std::net::IpAddr::V6(ip)) if ip.is_unspecified() => {
147            std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, port))
148        }
149        Ok(ip) => std::net::SocketAddr::new(ip, port),
150        Err(_) => std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port)),
151    }
152}
153
154fn startup_port_flag(surface: &str) -> &'static str {
155    match surface {
156        "FTAPI" => "--port",
157        "REST" => "--rest-port",
158        "gRPC" => "--grpc-port",
159        "WebSocket" => "--websocket-port",
160        "Telnet" => "--telnet-port",
161        _ => "--port",
162    }
163}
164
165pub(super) async fn run_phase1(config: &RuntimeConfig) -> Result<Phase1Out> {
166    // codex 0547 F6 (P3): 安全字段从 config 读 (而非 args.*) — TOML 也能
167    // override 这些 (与 docs "字段与 CLI 一致" 契约对齐).
168    let rest_keys_file = config.rest_keys_file.clone();
169    let ws_keys_file = config.ws_keys_file.clone();
170    let grpc_keys_file = config.grpc_keys_file.clone();
171    let audit_log = config.audit_log.clone();
172    let allow_tcp_unauthenticated = config.allow_tcp_unauthenticated;
173
174    // v1.4.104 external reviewer P1-003 (P1) fix: keys-file 解析时序前移到 broker auth /
175    // SMS 之前. 之前 schema 错的 keys-file 要等到 surface server 启动时才报错
176    // (broker auth + SMS 之后 7+ 秒), 配置错应该启动就发现.
177    //
178    // 这里只**预解析 + dry-run 验证**, 不持久化结果 (实际 Arc 在 surface server
179    // 启动时由 KeyStore::load 重新读+解析, 因为 SIGHUP reload 也走那条路径).
180    // dry-run 失败 → 立即 abort, 不进 broker auth.
181    for (label, path_opt) in [
182        ("REST", &rest_keys_file),
183        ("gRPC", &grpc_keys_file),
184        ("WS", &ws_keys_file),
185    ] {
186        if let Some(path) = path_opt {
187            match futu_auth::KeyStore::load(path) {
188                Ok(ks) => {
189                    tracing::info!(
190                        surface = label,
191                        path = %path.display(),
192                        keys_loaded = ks.len(),
193                        "v1.4.104 external report P1-003 (P1): {} keys file pre-validated OK \
194                         (broker auth not yet started)",
195                        label
196                    );
197                }
198                Err(e) => {
199                    tracing::error!(
200                        surface = label,
201                        error = %e,
202                        path = %path.display(),
203                        "v1.4.104 external report P1-003 (P1): {} keys file pre-validation FAILED — \
204                         abort before broker auth / SMS to fail-closed early",
205                        label
206                    );
207                    return Err(anyhow::anyhow!(
208                        "v1.4.104 external report P1-003 (P1) fix: {} keys file at {} failed schema \
209                         validation: {e}. abort before broker auth / SMS. fix the keys \
210                         file then restart.",
211                        label,
212                        path.display()
213                    ));
214                }
215            }
216        }
217    }
218    // codex 0547 F6 (P3): merge_config 已提前到 args 仍可用阶段 (~line 1006);
219    // 此处不再重复 capture inject_auth_failure_every / merge_config.
220    // dev-flags 在 cfg(feature = "dev-flags") 路径上, capture 已在 args 解析后立即做.
221
222    // 1. 初始化日志(--log-level 参数生效,RUST_LOG 环境变量优先)
223    // audit 日志 guard 必须活到进程退出,否则 tracing-appender 后台线程可能丢事件。
224    let _audit_guard = if config.json_log {
225        // v1.4.27(BUG-7,加拿大同事 v1.4.26 回归测试发现):`--audit-log` 和
226        // `--json-log` 一起用时,之前是**静默忽略** `--audit-log`(只打 warn
227        // 到 stderr)、创建空文件;用户会误以为"没有审计事件发生"。现在改
228        // 硬失败 → 用户必须显式二选一,避免审计文件空导致的合规事故。
229        if audit_log.is_some() {
230            eprintln!(
231                "error: --audit-log and --json-log are mutually exclusive.\n\
232                 - --json-log: entire stderr as JSONL (full event stream)\n\
233                 - --audit-log: only target=futu_audit events as JSONL to a file\n\
234                 choose one. If you need both machine-readable stderr AND a separate audit \
235                 file, open an issue — today's layer composition doesn't support it."
236            );
237            std::process::exit(2);
238        }
239        futu_core::log::init_json_logging_with_level(&config.log_level);
240        None
241    } else {
242        match futu_core::log::init_logging_with_audit(&config.log_level, audit_log.as_deref()) {
243            Ok(guard) => {
244                if let (Some(path), Some(_)) = (audit_log.as_ref(), guard.as_ref()) {
245                    tracing::info!(
246                        path = %path.display(),
247                        "audit JSONL logger enabled (target=futu_audit → file)"
248                    );
249                }
250                guard
251            }
252            Err(e) => {
253                eprintln!("warning: failed to init audit log: {e}");
254                futu_core::log::init_logging_with_level(&config.log_level);
255                None
256            }
257        }
258    };
259
260    spawn_language_pack_auto_update(config);
261
262    // v1.4.102 codex 27 F11 (P2) fix: startup chmod migration 移到无条件 path,
263    // 不再放在 login 分支里. 升级 (v1.4.101 及以前) 用户的
264    // `~/.futu-opend-rs/credentials-*.json` / `device-*.dat` 默认是 0644,
265    // 多用户机其他本地用户能读 tgtgt / web_sig. 此 fn 扫 ~/.futu-opend-rs/ 把
266    // secret 文件统一收紧到 0600.
267    //
268    // **历史**: BUG-012 修法 v1.4.102 ship 时把此 call 放在 `if let
269    // (Some(account), Some(password))` 分支内 — 无登录凭据 / 只跑 admin shell
270    // / 凭据解析失败的 daemon 都不执行 migration. codex 27 F11 audit 抓到.
271    //
272    // best-effort: chmod 失败 warn but don't fail (失败 != 凭据本身失效).
273    futu_backend::auth::tighten_secret_files_at_startup();
274
275    // v1.4.41 (P3.1 第二阶段): tracing subscriber 已装,重装 panic hook
276    // 让 panic 走 tracing::error!(audit log / JSON log / stderr 三出)。
277    std::panic::set_hook(Box::new(|info| {
278        let location = info
279            .location()
280            .map(|l| format!("{}:{}", l.file(), l.line()))
281            .unwrap_or_else(|| "<unknown>".to_string());
282        let payload = info
283            .payload()
284            .downcast_ref::<&str>()
285            .copied()
286            .or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
287            .unwrap_or("<non-string panic payload>");
288        let thread = std::thread::current()
289            .name()
290            .unwrap_or("<unnamed>")
291            .to_string();
292        tracing::error!(
293            target: "panic",
294            location = %location,
295            payload = %payload,
296            thread = %thread,
297            "PANIC caught by global hook"
298        );
299        eprintln!("PANIC at {location}: {payload} (thread={thread})");
300        // v1.4.97 P1-D-D: also write dated crash log to disk for forensics.
301        // Same as pre-tracing hook — covers panics that occur after tracing
302        // subscriber is up.
303        write_crash_log_file(info);
304        // v1.4.97 P1-D-E: propagate any panic → process exit so systemd
305        // Restart=on-failure can restart the daemon. Without this, tokio
306        // task panic silently kills only the task, leaving daemon zombie
307        // (REST/gRPC/WS/telnet/push tasks die one-by-one with main alive).
308        //
309        // Aligned with C++ NNCrashCenter `exit(NN_ExitCode_Crash)` pattern
310        // (NNCrashCenter_Mac.cpp:99; per agent 9 finding).
311        // `exit(101)` matches Rust panic default exit code; not used in
312        // test build (cfg(not(test))) to avoid disrupting unit tests.
313        #[cfg(not(test))]
314        std::process::exit(101);
315    }));
316
317    // 2. install 全局 metrics registry(让 audit::* 的 counter hook 和
318    //    REST `/metrics` 端点能对齐同一套计数器)
319    futu_auth::metrics::install(std::sync::Arc::new(futu_auth::MetricsRegistry::default()));
320
321    // 2.1 共享 RuntimeCounters:REST / gRPC 共用一个,这样 rate limit 和日累计
322    //     跨接口一致(同一把 key 通过 REST 下 3 单、gRPC 下 3 单,rate 窗口
323    //     看到 6 单,不是各看 3 单)
324    let shared_counters = std::sync::Arc::new(futu_auth::RuntimeCounters::new());
325
326    let listen_addr = format!("{}:{}", config.ip, config.port);
327    tracing::info!(addr = %listen_addr, "starting FutuOpenD Rust Gateway");
328
329    // v1.4.42 (external reviewer v1.4.40 报告 P3.5 澄清): moomoo 账户 + 显式 --login-region
330    // → WARN 提示此 flag 对 moomoo 账户 noop(不影响 platform IP 选择)。
331    //
332    // 原因:login_region 只用在 CN 手机号账户的 salt URL `region_no` 参数,
333    // platform IP 池按 user_attribution(CN/HK/US/SG/AU/JP)从 conn_points
334    // 选,不按 login_region 切(3 个 region 代号 gz/sh/hk 是 CN 大陆分区,
335    // 和 platform IP 池没对应关系)。external reviewer v1.4.40 报告观察 "三种 region 下
336    // platform IP 相同" 是预期行为,不是 bug。
337    //
338    // v1.4.40 计划中"加 WARN"实际没加(CHANGELOG 声称但代码漏),v1.4.42 补上。
339    if config.login_region_explicit && matches!(config.platform, Platform::Moomoo) {
340        tracing::warn!(
341            login_region = %config.login_region,
342            platform = "moomoo",
343            "--login-region={region} is a NO-OP for moomoo accounts — flag only \
344             applies to --platform futunn + CN phone-number login. moomoo accounts \
345             route via user_attribution automatically. Observed \"same platform IP \
346             across gz/sh/hk\" is expected behavior. Remove --login-region to silence.",
347            region = config.login_region
348        );
349    }
350
351    // v1.4.16:端口冲突检测——在登录之前先检查核心端口是否已被占用。
352    // 多实例并行(如同时跑 futunn + moomoo)是预期场景,但用户容易忘记改端口,
353    // 导致 futucli 连到了旧实例看到错账号的数据(同事 bug report #6)。
354    {
355        let ports_to_check: Vec<(&str, &str, u16)> =
356            std::iter::once(("FTAPI", config.ip.as_str(), config.port))
357                .chain(config.rest_port.map(|p| ("REST", config.ip.as_str(), p)))
358                .chain(config.grpc_port.map(|p| ("gRPC", config.ip.as_str(), p)))
359                .chain(
360                    config
361                        .websocket_port
362                        .map(|p| ("WebSocket", config.ip.as_str(), p)),
363                )
364                .chain(
365                    config
366                        .telnet_port
367                        .map(|p| ("Telnet", config.telnet_ip.as_str(), p)),
368                )
369                .collect();
370        for (name, bind_ip, port) in &ports_to_check {
371            let addr = port_probe_addr(bind_ip, *port);
372            if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200))
373                .is_ok()
374            {
375                let hint = futu_server::bind_hint::port_conflict_message(
376                    name,
377                    startup_port_flag(name),
378                    &addr.to_string(),
379                );
380                tracing::warn!(
381                    name,
382                    port,
383                    hint = %hint,
384                    "startup port conflict probe detected an existing listener"
385                );
386                eprintln!(
387                    "warning: startup port conflict probe detected an existing listener: {hint}"
388                );
389            }
390        }
391    }
392
393    Ok(Phase1Out {
394        _audit_guard,
395        shared_counters,
396        listen_addr,
397        rest_keys_file,
398        ws_keys_file,
399        grpc_keys_file,
400        allow_tcp_unauthenticated,
401    })
402}
403
404#[cfg(test)]
405mod tests;