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