Skip to main content

futu_opend/
config.rs

1//! v1.4.110 P1-2: XmlConfig + RuntimeConfig + load / merge 抽自 main.rs lines 498-842.
2
3use anyhow::{Context, Result};
4
5use crate::cli::{Args, LogLevel, LoginRegion, Platform};
6
7mod runtime_language;
8
9use runtime_language::resolve_process_runtime_language;
10pub(crate) use runtime_language::{RuntimeLanguageSource, app_lang_from_runtime_lang};
11
12// ===== XML 配置文件解析 (兼容 C++ FutuOpenD.xml) =====
13//
14// v1.4.102 BUG-006 fix (P1, leaf v1.4.100 报告): `#[serde(deny_unknown_fields)]`
15// 让 TOML/XML 里未知字段 / 未知 section ([daemon]) 报 fatal error 而非 silent
16// drop. 历史: 用户写 `[daemon]\nport = 12482` (合理 namespace, 但 XmlConfig
17// 是 flat 结构), serde 把整个 `[daemon]` 作为未知字段 silent drop, daemon
18// log 显示 "loaded TOML config" 但端口仍是默认值 11111. P1 配置 silent 失效.
19//
20// **修法**: deny_unknown_fields → `[daemon]` section / typo 立即 fatal +
21// 启动 abort. 用户改成 flat keys (`port = 12482` 顶层) 才合法.
22//
23// **不接受 [daemon] section 的设计理由**: XmlConfig 是 quick_xml 生成的
24// flat schema, 加 nested DaemonSection 会破坏 XML 兼容路径 (XML 不分 section).
25// 一致性: TOML / XML 都用 flat keys, 文档明确说.
26#[derive(Default, serde::Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct XmlConfig {
29    // 登录参数
30    pub login_account: Option<String>,
31    pub login_pwd: Option<String>,
32    pub login_pwd_md5: Option<String>,
33    pub login_pwd_file: Option<String>,
34    // v1.4.40 #12 fix: XML/TOML 里 login_region 也封闭为 enum。serde 会拒非法值。
35    pub login_region: Option<LoginRegion>,
36    /// 账号平台(v1.4.14+)—— "futunn" 或 "moomoo"
37    pub platform: Option<Platform>,
38    // 服务监听
39    pub ip: Option<String>,
40    #[serde(alias = "api_port")]
41    pub port: Option<u16>,
42    // WebSocket
43    pub websocket_port: Option<u16>,
44    // Telnet
45    pub telnet_port: Option<u16>,
46    pub telnet_ip: Option<String>,
47    // REST API
48    pub rest_port: Option<u16>,
49    pub rest_tls_cert: Option<std::path::PathBuf>,
50    pub rest_tls_key: Option<std::path::PathBuf>,
51    // gRPC
52    pub grpc_port: Option<u16>,
53    // RSA
54    pub rsa_private_key: Option<String>,
55    // 系统
56    pub lang: Option<String>,
57    // codex 0547 F3 (P2) fix: 改 Option<LogLevel> enum (与 clap ValueEnum
58    // 同源校验). 之前是 Option<String> + 运行时手动 LogLevel::from_str_opt,
59    // 非法值 (e.g. `log_level = "wtf"`) 走 eprintln + fallback to "info" —
60    // BUG-006 fatal-on-typo 语义不继承到此字段. 修后 serde 在 TOML/XML
61    // parse 阶段直接拒非法值 → daemon abort + 清晰错误 (包含合法 enum list).
62    //
63    // serde rename: LogLevel 已 `#[serde(rename_all = "lowercase")]`, 所以
64    // TOML `log_level = "info"` / `"debug"` / "off" 全合法, `"wtf"` /
65    // `"warning"` (老 alias) 此 path 直接 reject. 旧 lenient alias 只保留在
66    // `LogLevel::from_str_opt` 测试 helper 中,防止重新接回 production。
67    pub log_level: Option<LogLevel>,
68    // codex 0547 F6 (P3) fix: 扩展 schema 让 systemd / Docker 部署能把完整
69    // 配置 (含安全相关字段) 收敛进 TOML, 与 `--config` help 文 "字段与 CLI
70    // 一致" 契约对齐. 之前 schema 只覆盖登录/监听/RSA/lang/log_level 5 类;
71    // 用户在 TOML 写 `rest_keys_file = "..."` 触发 BUG-006 unknown field
72    // fatal. 现 schema 含安全字段, 文档同步白名单.
73    //
74    // CLI-only 字段 (故意不进 TOML, 见下方 `--config` help 段白名单):
75    // `device_id` / `reset_device` / `setup_only` / `verify_code` /
76    // `json_log` / `inject_auth_failure_every` — 运维 / 调试 / 一次性流程
77    // flag, 不适合写持久 config 文件.
78    pub rest_keys_file: Option<std::path::PathBuf>,
79    pub grpc_keys_file: Option<std::path::PathBuf>,
80    pub ws_keys_file: Option<std::path::PathBuf>,
81    pub audit_log: Option<std::path::PathBuf>,
82    pub allow_tcp_unauthenticated: Option<bool>,
83    /// IANA timezone name (e.g. "Asia/Hong_Kong"). 与 --tz CLI flag 同源.
84    pub tz: Option<String>,
85    /// Language-pack background update. Defaults true in RuntimeConfig.
86    pub language_pack_auto_update: Option<bool>,
87    /// Language-pack exact manifest URL, or a directory URL ending in `/`.
88    /// Directory URLs append `manifest.json`. Empty means no network.
89    pub language_pack_endpoint: Option<String>,
90    /// Language-pack cache root.
91    pub language_pack_cache_dir: Option<std::path::PathBuf>,
92    /// Language-pack update HTTP timeout in milliseconds.
93    pub language_pack_update_timeout_ms: Option<u64>,
94    /// Long-runner opt-in: proactive client_sig refresh 1h before expiry.
95    pub client_sig_proactive_refresh: Option<bool>,
96    /// Long-runner opt-in: reactive client_sig refresh after repeated tcp_login failures.
97    pub client_sig_reactive_refresh: Option<bool>,
98    /// C++ RequestedKline UserCloudConfig synchronization. Defaults true.
99    pub history_kline_cloud_sync: Option<bool>,
100}
101
102impl std::fmt::Debug for XmlConfig {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        let login_account_fp = self
105            .login_account
106            .as_deref()
107            .map(futu_backend::auth::redact::account_log_fingerprint);
108        let login_pwd = redact_debug_option(&self.login_pwd);
109        let login_pwd_md5 = redact_debug_option(&self.login_pwd_md5);
110
111        f.debug_struct("XmlConfig")
112            .field("login_account_fp", &login_account_fp)
113            .field("login_pwd", &login_pwd)
114            .field("login_pwd_md5", &login_pwd_md5)
115            .field("login_pwd_file", &self.login_pwd_file)
116            .field("login_region", &self.login_region)
117            .field("platform", &self.platform)
118            .field("ip", &self.ip)
119            .field("port", &self.port)
120            .field("websocket_port", &self.websocket_port)
121            .field("telnet_port", &self.telnet_port)
122            .field("telnet_ip", &self.telnet_ip)
123            .field("rest_port", &self.rest_port)
124            .field("rest_tls_cert", &self.rest_tls_cert)
125            .field("rest_tls_key", &self.rest_tls_key)
126            .field("grpc_port", &self.grpc_port)
127            .field("rsa_private_key", &self.rsa_private_key)
128            .field("lang", &self.lang)
129            .field("log_level", &self.log_level)
130            .field("rest_keys_file", &self.rest_keys_file)
131            .field("grpc_keys_file", &self.grpc_keys_file)
132            .field("ws_keys_file", &self.ws_keys_file)
133            .field("audit_log", &self.audit_log)
134            .field("allow_tcp_unauthenticated", &self.allow_tcp_unauthenticated)
135            .field("tz", &self.tz)
136            .field("language_pack_auto_update", &self.language_pack_auto_update)
137            .field("language_pack_endpoint", &self.language_pack_endpoint)
138            .field("language_pack_cache_dir", &self.language_pack_cache_dir)
139            .field(
140                "language_pack_update_timeout_ms",
141                &self.language_pack_update_timeout_ms,
142            )
143            .field(
144                "client_sig_proactive_refresh",
145                &self.client_sig_proactive_refresh,
146            )
147            .field(
148                "client_sig_reactive_refresh",
149                &self.client_sig_reactive_refresh,
150            )
151            .field("history_kline_cloud_sync", &self.history_kline_cloud_sync)
152            .finish()
153    }
154}
155
156fn redact_debug_option(value: &Option<String>) -> String {
157    match value {
158        Some(value) => format!("<REDACTED len={}>", value.len()),
159        None => "<NONE>".to_string(),
160    }
161}
162
163/// 从 XML 配置文件读取配置 (兼容 C++ <futu_opend> 格式)
164pub fn load_xml_config(path: &str) -> Result<XmlConfig> {
165    let content = std::fs::read_to_string(path)?;
166    let config: XmlConfig = quick_xml::de::from_str(&content)?;
167    tracing::info!(path, "loaded XML config");
168    Ok(config)
169}
170
171/// 从 TOML 配置文件读取配置 (v1.4.2+;字段与 XmlConfig 一致)
172///
173/// v1.4.102 BUG-006: XmlConfig 加了 `#[serde(deny_unknown_fields)]`, 任何
174/// section ([daemon] 等) / typo / unknown key 直接报 fatal error 而非
175/// silent drop (历史 P1 bug: `[daemon]\nport=12482` daemon log 说 loaded
176/// 但端口实际是 default 11111).
177pub fn load_toml_config(path: &str) -> Result<XmlConfig> {
178    let content = std::fs::read_to_string(path)?;
179    let config: XmlConfig = toml::from_str(&content).map_err(|e| {
180        // v1.4.102 BUG-006: 给 deny_unknown_fields 触发的错加 hint
181        let msg = e.to_string();
182        if msg.contains("unknown field") {
183            anyhow::anyhow!(
184                "TOML config parse error: {msg}\n\
185                 Hint (v1.4.102 BUG-006): TOML 配置必须用 **flat keys**, 不支持 \
186                 [section] 嵌套 (如 `[daemon]\\nport = X` 会触发本 error). \
187                 正确写法: `port = 12482` 直接放在文件顶层 (no [section] header). \
188                 详见 README.md §TOML 配置 / 项目根 deploy/examples/futu-opend.toml 示例."
189            )
190        } else {
191            anyhow::Error::from(e)
192        }
193    })?;
194    // tracing 初始化在 main 里,此时还没 subscriber,用 eprintln
195    eprintln!("[config] loaded TOML config from {path}");
196    Ok(config)
197}
198
199/// 合并后的运行时配置
200pub struct RuntimeConfig {
201    pub ip: String,
202    pub port: u16,
203    pub login_account: Option<String>,
204    pub login_pwd: Option<String>,
205    pub login_pwd_md5: Option<String>,
206    pub login_pwd_file: Option<String>,
207    pub login_region: String,
208    /// v1.4.42 (external reviewer v1.4.40 报告 P3.5 澄清): 标记用户是否显式传了 `--login-region`
209    /// (或在 config 文件里写了)。平台是 moomoo + 用户显式传 region → main() 入口 WARN
210    /// 明示此 flag 对 moomoo 是 noop。避免用户误以为 "layer 2/3 platform IP 按 region 切"。
211    pub login_region_explicit: bool,
212    pub platform: Platform,
213    pub auth_server: String,
214    pub device_id: Option<String>,
215    pub reset_device: bool,
216    pub setup_only: bool,
217    /// v1.4.57 UX-04: 直接传入 SMS 验证码跳过 stdin 交互(Telegram 中继等场景)
218    pub verify_code: Option<String>,
219    pub log_level: String,
220    pub websocket_port: Option<u16>,
221    pub telnet_port: Option<u16>,
222    pub telnet_ip: String,
223    pub rest_port: Option<u16>,
224    pub rest_tls_cert: Option<std::path::PathBuf>,
225    pub rest_tls_key: Option<std::path::PathBuf>,
226    pub grpc_port: Option<u16>,
227    pub rsa_private_key: Option<String>,
228    pub json_log: bool,
229    pub lang: String,
230    pub(crate) runtime_language_source: RuntimeLanguageSource,
231    // codex 0547 F6 (P3): TOML 安全字段 schema 扩展. main() 之前各自从
232    // `args.*` 拷, 现统一从 RuntimeConfig 读, TOML 配置生效路径打通.
233    pub rest_keys_file: Option<std::path::PathBuf>,
234    pub grpc_keys_file: Option<std::path::PathBuf>,
235    pub ws_keys_file: Option<std::path::PathBuf>,
236    pub audit_log: Option<std::path::PathBuf>,
237    pub allow_tcp_unauthenticated: bool,
238    pub tz: Option<String>,
239    pub language_pack_auto_update: bool,
240    pub language_pack_endpoint: Option<String>,
241    pub language_pack_cache_dir: Option<std::path::PathBuf>,
242    pub language_pack_update_timeout_ms: u64,
243    pub client_sig_proactive_refresh: bool,
244    pub client_sig_reactive_refresh: bool,
245    pub history_kline_cloud_sync: bool,
246    /// Rust product update manifest used by GetUserInfo(Update).
247    pub update_check_url: String,
248}
249
250fn clean_optional_string(value: Option<String>) -> Option<String> {
251    value
252        .map(|value| value.trim().to_string())
253        .filter(|value| !value.is_empty())
254}
255
256fn env_optional_string(key: &str) -> Option<String> {
257    std::env::var(key)
258        .ok()
259        .and_then(|value| clean_optional_string(Some(value)))
260}
261
262fn env_optional_path(key: &str) -> Option<std::path::PathBuf> {
263    env_optional_string(key).map(std::path::PathBuf::from)
264}
265
266fn env_optional_u64(key: &str) -> Option<u64> {
267    env_optional_string(key).and_then(|value| value.parse::<u64>().ok())
268}
269
270fn resolve_history_kline_cloud_sync(
271    cli: Option<bool>,
272    config: Option<bool>,
273    env: Option<&str>,
274) -> Result<bool> {
275    if let Some(value) = cli.or(config) {
276        return Ok(value);
277    }
278    let Some(raw) = env else {
279        return Ok(true);
280    };
281    raw.trim().parse::<bool>().map_err(|_| {
282        anyhow::anyhow!("FUTU_HISTORY_KLINE_CLOUD_SYNC must be true or false, got {raw:?}")
283    })
284}
285
286/// codex 0547 F1+F2 (P2) — explicit user-supplied credential / secret 文件
287/// 读取 helper. 用户**显式**(CLI flag 或 TOML/XML config) 传入路径但读取失败
288/// 时 fail-closed 返 Err. 让 daemon abort 而非 silent fallback.
289///
290/// **范围**: 只覆盖 user-supplied (explicit) credential 路径; auto-detect 路径
291/// 由 caller 自己选 fallback 行为. 当前调用点:
292///
293/// - `rsa_private_key`: `--rsa-private-key` / `[rsa_private_key]` (F1)
294/// - `login_pwd_file`: `--login-pwd-file` / `[login_pwd_file]` (F2; 仍走老
295///   resolve_login_password 7 层链, 但 explicit path 失败 = fatal)
296///
297/// **fail-closed 触发**:
298/// - 文件不存在 / 权限不够 / IO error → Err
299/// - 文件存在但 trim 后为空 → Err (用户传了路径但内容空 = 不一致, 常见
300///   原因: systemd LoadCredential 失败 / Docker secret mount 漏挂 / file
301///   truncated)
302///
303/// **返回**: `Ok(content_after_trim_trailing_whitespace)`, 失败 `Err`.
304///
305/// 调用方对 `Err` 的标准处理是直接 `?` propagate 让 daemon abort.
306pub fn read_explicit_credential_file(field_label: &'static str, path: &str) -> Result<String> {
307    let raw = std::fs::read_to_string(path).map_err(|e| {
308        anyhow::anyhow!(
309            "audit 0547 (P2) fix: failed to read explicit {field_label} from {path}: {e}\n\
310             Explicit credential / secret 路径读失败现在 fail-closed (daemon \
311             abort), 不再 silent fallback. 检查: 文件存在 / 权限 / systemd \
312             LoadCredential= / Docker secret mount. 如要 explicit opt-out fail-closed \
313             behavior, 不传该 flag 即可 (会走 auto-detect / 其他来源)."
314        )
315    })?;
316    let content = raw.trim_end_matches(['\n', '\r', ' ', '\t']).to_string();
317    if content.is_empty() {
318        return Err(anyhow::anyhow!(
319            "audit 0547 (P2) fix: explicit {field_label} at {path} is empty (after \
320             trim). 不允许 (常见原因: secret mount 失败 / file truncated / write \
321             race). 修文件后重启."
322        ));
323    }
324    Ok(content)
325}
326
327/// 合并 CLI args + 配置文件 (CLI 优先)
328///
329/// 配置文件查找顺序:
330///   1. `--config <path>`:TOML(v1.4.2+)
331///   2. `--cfg-file <path>`:XML(兼容 C++ FutuOpenD.xml)
332///   3. 自动检测:同目录 futu-opend.toml → FutuOpenD.xml
333fn resolve_allow_tcp_unauthenticated(
334    cli_allow: bool,
335    cli_deny: bool,
336    configured: Option<bool>,
337) -> bool {
338    debug_assert!(!(cli_allow && cli_deny), "clap rejects conflicting flags");
339    if cli_deny {
340        false
341    } else if cli_allow {
342        true
343    } else {
344        configured.unwrap_or(false)
345    }
346}
347
348pub fn merge_config(args: Args) -> Result<RuntimeConfig> {
349    // v1.4.102 codex 24 F1 (P1) fix: explicit `--config` / `--cfg-file` 加载
350    // 失败必须 abort, 不再 silent fallback 到 default.
351    //
352    // **历史**: BUG-006 修法只让 `XmlConfig` 加 `deny_unknown_fields`, 但
353    // 实际 daemon 启动路径在这里把任何 parse error catch 后用 default 继续
354    // 跑. 用户写错的 `[daemon]` section 在 `XmlConfig::deserialize` 报错,
355    // 但 daemon 仍以 default 启动 (`port=11111` etc.) — silent ignore 并未
356    // 真正修在 runtime path 上.
357    //
358    // **修法 (codex 24 F1)**: explicit path → `?` 返 error abort daemon.
359    // **auto-detect path 仍 fallback** (用户没显式指定文件 → default 是合理
360    // 行为, 不改).
361    let xml = if let Some(ref path) = args.config {
362        // TOML 显式指定 → fail-closed (BUG-006 真修, codex 24 F1)
363        load_toml_config(path).map_err(|e| {
364            anyhow::anyhow!(
365                "failed to load explicit --config TOML at {path}: {e}\n\
366                 v1.4.102 codex 24 F1 (P1) fix: explicit config 解析失败 daemon abort \
367                 (不再 silent fallback to default)."
368            )
369        })?
370    } else if let Some(ref path) = args.cfg_file {
371        // XML 显式指定 → fail-closed
372        load_xml_config(path).map_err(|e| {
373            anyhow::anyhow!(
374                "failed to load explicit --cfg-file XML at {path}: {e}\n\
375                 v1.4.102 codex 24 F1 (P1) fix: explicit config 解析失败 daemon abort \
376                 (不再 silent fallback to default)."
377            )
378        })?
379    } else {
380        // 自动检测:优先 futu-opend.toml,其次 FutuOpenD.xml
381        let exe_dir = std::env::current_exe()
382            .ok()
383            .and_then(|p| p.parent().map(|d| d.to_path_buf()));
384        if let Some(ref dir) = exe_dir {
385            let toml_path = dir.join("futu-opend.toml");
386            let xml_path = dir.join("FutuOpenD.xml");
387            // v1.4.102 codex 31 F1 (P1) fix: auto-detect config 解析失败也
388            // fail-closed (与 explicit --config / --cfg-file 一致). 之前
389            // unwrap_or_default 让带 [daemon] section 的 auto-loaded TOML
390            // silent fallback to XmlConfig::default(), 与 BUG-006 fatal claim
391            // 不一致.
392            if toml_path.exists() {
393                load_toml_config(&toml_path.to_string_lossy()).map_err(|e| {
394                    anyhow::anyhow!(
395                        "failed to parse auto-detected futu-opend.toml at {}: {e}\n\
396                         v1.4.102 codex 31 F1 (P1): auto-detected config parse \
397                         failure 现在也 fail-closed (与 explicit --config 一致). \
398                         如不希望此文件加载, 删除或重命名即可.",
399                        toml_path.display()
400                    )
401                })?
402            } else if xml_path.exists() {
403                load_xml_config(&xml_path.to_string_lossy()).map_err(|e| {
404                    anyhow::anyhow!(
405                        "failed to parse auto-detected FutuOpenD.xml at {}: {e}\n\
406                         v1.4.102 codex 31 F1 (P1): auto-detected config parse \
407                         failure 现在也 fail-closed.",
408                        xml_path.display()
409                    )
410                })?
411            } else {
412                XmlConfig::default()
413            }
414        } else {
415            XmlConfig::default()
416        }
417    };
418
419    let runtime_language =
420        resolve_process_runtime_language(args.lang.as_deref(), xml.lang.as_deref());
421
422    Ok(RuntimeConfig {
423        ip: args.ip.or(xml.ip).unwrap_or_else(|| "0.0.0.0".to_string()),
424        port: args.port.or(xml.port).unwrap_or(11111),
425        login_account: args.login_account.or(xml.login_account),
426        login_pwd: args.login_pwd.or(xml.login_pwd),
427        login_pwd_md5: args.login_pwd_md5.or(xml.login_pwd_md5),
428        login_pwd_file: args.login_pwd_file.or(xml.login_pwd_file),
429        // v1.4.40 #12 fix: LoginRegion enum (gz/sh/hk 三个合法值) → string 给下游 auth
430        // 使用。enum 已保证合法性,这里 as_str() 转回内部约定的 lowercase 形式。
431        // v1.4.42 (P3.5 澄清): login_region_explicit 记录 "是否用户显式传了",
432        // 用于 main() 早期对 moomoo 账户 WARN 这个 flag 对他们 noop。
433        login_region_explicit: args.login_region.is_some() || xml.login_region.is_some(),
434        login_region: args
435            .login_region
436            .or(xml.login_region)
437            .map(|r| r.as_str().to_string())
438            .unwrap_or_else(|| "gz".to_string()),
439        // v1.4.14:auth_server 优先级
440        //   1. `--auth-server <url>` 显式 URL(最高)
441        //   2. `--platform moomoo/futunn` → auth.moomoo.com / auth.futunn.com
442        //   3. XML/TOML 里的 platform 字段
443        //   4. 默认 auth.futunn.com
444        platform: args.platform.or(xml.platform).unwrap_or_default(),
445        auth_server: args.auth_server.unwrap_or_else(|| {
446            args.platform
447                .or(xml.platform)
448                .unwrap_or_default()
449                .auth_server()
450                .to_string()
451        }),
452        log_level: {
453            // codex 0547 F3 (P2) fix: XmlConfig.log_level 改 Option<LogLevel>
454            // 之后, 非法值在 toml::from_str / quick_xml::de::from_str 阶段直接
455            // reject (与 BUG-006 deny_unknown_fields 同语义级别). 这里只把
456            // CLI / config enum 转 lowercase string 给下游使用.
457            //
458            // v1.4.73 BUG-015 老语义 (eprintln + fallback to "info") 已被淘汰 —
459            // explicit user 输入有 typo 必须 daemon abort, 不能 silent
460            // 用 default 跑 (反模式 D / pitfall #45 silent-success).
461            args.log_level
462                .or(xml.log_level)
463                .map(|l| l.as_str().to_string())
464                .unwrap_or_else(|| "info".to_string())
465        },
466        websocket_port: args.websocket_port.or(xml.websocket_port),
467        telnet_port: args.telnet_port.or(xml.telnet_port),
468        // Ref: C++ FutuOpenD FTGateway.cpp:1300-1302 — empty
469        // GTW_CfgKey_TelnetIP falls back to 127.0.0.1, independent of the
470        // main API bind IP. Keep the same default so enabling telnet on a
471        // daemon bound to 0.0.0.0 does not accidentally expose the unauthenticated
472        // management port.
473        telnet_ip: args
474            .telnet_ip
475            .or(xml.telnet_ip)
476            .unwrap_or_else(|| "127.0.0.1".to_string()),
477        rest_port: args.rest_port.or(xml.rest_port),
478        rest_tls_cert: args.rest_tls_cert.or(xml.rest_tls_cert),
479        rest_tls_key: args.rest_tls_key.or(xml.rest_tls_key),
480        grpc_port: args.grpc_port.or(xml.grpc_port),
481        rsa_private_key: {
482            // codex 0547 F1 (P2) fix: 显式 `--rsa-private-key` / TOML/XML
483            // `rsa_private_key` 路径读失败必须 fail-closed (返 Err 让 daemon
484            // abort), 不再 silent fallback to "no RSA"。systemd `LoadCredential=`
485            // / Docker secret mount 失败时 daemon 仍跑变成无 RSA 模式 = 安全
486            // 配置 silent 失效。统一走 `read_explicit_credential_file` helper.
487            let key_path = args.rsa_private_key.or(xml.rsa_private_key);
488            if let Some(ref path) = key_path {
489                let pem = read_explicit_credential_file("--rsa-private-key", path)?;
490                // C++ validates configured material with
491                // PEM_read_bio_RSAPrivateKey + RSA_check_key before enabling
492                // RSA (FTGTW_Inner_API.cpp:588-602). Rust deliberately keeps
493                // its established stricter explicit-config policy: invalid
494                // material aborts startup instead of silently disabling RSA.
495                futu_net::encrypt::validate_rsa_private_key(&pem)
496                    .map_err(anyhow::Error::new)
497                    .with_context(|| {
498                        format!("invalid --rsa-private-key material from configured path {path}")
499                    })?;
500                eprintln!("loaded RSA private key from {path}");
501                Some(pem)
502            } else {
503                None
504            }
505        },
506        device_id: args.device_id,
507        reset_device: args.reset_device,
508        setup_only: args.setup_only,
509        verify_code: args.verify_code,
510        json_log: args.json_log,
511        lang: runtime_language.lang,
512        runtime_language_source: runtime_language.source,
513        language_pack_auto_update: args
514            .language_pack_auto_update
515            .or(xml.language_pack_auto_update)
516            .unwrap_or(true),
517        language_pack_endpoint: clean_optional_string(args.language_pack_endpoint)
518            .or_else(|| clean_optional_string(xml.language_pack_endpoint))
519            .or_else(|| env_optional_string("FUTU_LANGUAGE_PACK_ENDPOINT")),
520        language_pack_cache_dir: args
521            .language_pack_cache_dir
522            .or(xml.language_pack_cache_dir)
523            .or_else(|| env_optional_path("FUTU_LANGUAGE_PACK_CACHE_DIR")),
524        language_pack_update_timeout_ms: args
525            .language_pack_update_timeout_ms
526            .or(xml.language_pack_update_timeout_ms)
527            .or_else(|| env_optional_u64("FUTU_LANGUAGE_PACK_UPDATE_TIMEOUT_MS"))
528            .unwrap_or(3000),
529        // 安全字段遵循 CLI 显式值 > TOML/XML > 默认值。FTAPI 无鉴权
530        // opt-in 同时提供正/负开关,以便运维可以撤销持久配置。
531        rest_keys_file: args.rest_keys_file.or(xml.rest_keys_file),
532        grpc_keys_file: args.grpc_keys_file.or(xml.grpc_keys_file),
533        ws_keys_file: args.ws_keys_file.or(xml.ws_keys_file),
534        audit_log: args.audit_log.or(xml.audit_log),
535        allow_tcp_unauthenticated: resolve_allow_tcp_unauthenticated(
536            args.allow_tcp_unauthenticated,
537            args.no_allow_tcp_unauthenticated,
538            xml.allow_tcp_unauthenticated,
539        ),
540        tz: args.tz.or(xml.tz),
541        // Long-runner client_sig auto-refresh stays explicit opt-in. Bool CLI
542        // flags follow the same "true overrides, false means absent" pattern as
543        // allow_tcp_unauthenticated; env compatibility is resolved in gateway-core.
544        client_sig_proactive_refresh: args.client_sig_proactive_refresh
545            || xml.client_sig_proactive_refresh.unwrap_or(false),
546        client_sig_reactive_refresh: args.client_sig_reactive_refresh
547            || xml.client_sig_reactive_refresh.unwrap_or(false),
548        history_kline_cloud_sync: resolve_history_kline_cloud_sync(
549            args.history_kline_cloud_sync,
550            xml.history_kline_cloud_sync,
551            std::env::var("FUTU_HISTORY_KLINE_CLOUD_SYNC")
552                .ok()
553                .as_deref(),
554        )?,
555        update_check_url: futu_core::update_check::resolve_update_check_url(None),
556    })
557}
558
559#[cfg(test)]
560mod tests;