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::Result;
4
5use crate::cli::{Args, LogLevel, LoginRegion, Platform};
6
7// ===== XML 配置文件解析 (兼容 C++ FutuOpenD.xml) =====
8//
9// v1.4.102 BUG-006 fix (P1, leaf v1.4.100 报告): `#[serde(deny_unknown_fields)]`
10// 让 TOML/XML 里未知字段 / 未知 section ([daemon]) 报 fatal error 而非 silent
11// drop. 历史: 用户写 `[daemon]\nport = 12482` (合理 namespace, 但 XmlConfig
12// 是 flat 结构), serde 把整个 `[daemon]` 作为未知字段 silent drop, daemon
13// log 显示 "loaded TOML config" 但端口仍是默认值 11111. P1 配置 silent 失效.
14//
15// **修法**: deny_unknown_fields → `[daemon]` section / typo 立即 fatal +
16// 启动 abort. 用户改成 flat keys (`port = 12482` 顶层) 才合法.
17//
18// **不接受 [daemon] section 的设计理由**: XmlConfig 是 quick_xml 生成的
19// flat schema, 加 nested DaemonSection 会破坏 XML 兼容路径 (XML 不分 section).
20// 一致性: TOML / XML 都用 flat keys, 文档明确说.
21#[derive(Default, serde::Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct XmlConfig {
24    // 登录参数
25    pub login_account: Option<String>,
26    pub login_pwd: Option<String>,
27    pub login_pwd_md5: Option<String>,
28    pub login_pwd_file: Option<String>,
29    // v1.4.40 #12 fix: XML/TOML 里 login_region 也封闭为 enum。serde 会拒非法值。
30    pub login_region: Option<LoginRegion>,
31    /// 账号平台(v1.4.14+)—— "futunn" 或 "moomoo"
32    pub platform: Option<Platform>,
33    // 服务监听
34    pub ip: Option<String>,
35    #[serde(alias = "api_port")]
36    pub port: Option<u16>,
37    // WebSocket
38    pub websocket_port: Option<u16>,
39    // Telnet
40    pub telnet_port: Option<u16>,
41    // REST API
42    pub rest_port: Option<u16>,
43    // gRPC
44    pub grpc_port: Option<u16>,
45    // RSA
46    pub rsa_private_key: Option<String>,
47    // 系统
48    pub lang: Option<String>,
49    // codex 0547 F3 (P2) fix: 改 Option<LogLevel> enum (与 clap ValueEnum
50    // 同源校验). 之前是 Option<String> + 运行时手动 LogLevel::from_str_opt,
51    // 非法值 (e.g. `log_level = "wtf"`) 走 eprintln + fallback to "info" —
52    // BUG-006 fatal-on-typo 语义不继承到此字段. 修后 serde 在 TOML/XML
53    // parse 阶段直接拒非法值 → daemon abort + 清晰错误 (包含合法 enum list).
54    //
55    // serde rename: LogLevel 已 `#[serde(rename_all = "lowercase")]`, 所以
56    // TOML `log_level = "info"` / `"debug"` / "off" 全合法, `"wtf"` /
57    // `"warning"` (老 alias) 此 path 直接 reject. 旧 lenient alias 只保留在
58    // `LogLevel::from_str_opt` 测试 helper 中,防止重新接回 production。
59    pub log_level: Option<LogLevel>,
60    // codex 0547 F6 (P3) fix: 扩展 schema 让 systemd / Docker 部署能把完整
61    // 配置 (含安全相关字段) 收敛进 TOML, 与 `--config` help 文 "字段与 CLI
62    // 一致" 契约对齐. 之前 schema 只覆盖登录/监听/RSA/lang/log_level 5 类;
63    // 用户在 TOML 写 `rest_keys_file = "..."` 触发 BUG-006 unknown field
64    // fatal. 现 schema 含安全字段, 文档同步白名单.
65    //
66    // CLI-only 字段 (故意不进 TOML, 见下方 `--config` help 段白名单):
67    // `device_id` / `reset_device` / `setup_only` / `verify_code` /
68    // `json_log` / `inject_auth_failure_every` — 运维 / 调试 / 一次性流程
69    // flag, 不适合写持久 config 文件.
70    pub rest_keys_file: Option<std::path::PathBuf>,
71    pub grpc_keys_file: Option<std::path::PathBuf>,
72    pub ws_keys_file: Option<std::path::PathBuf>,
73    pub audit_log: Option<std::path::PathBuf>,
74    pub allow_tcp_unauthenticated: Option<bool>,
75    /// IANA timezone name (e.g. "Asia/Hong_Kong"). 与 --tz CLI flag 同源.
76    pub tz: Option<String>,
77}
78
79impl std::fmt::Debug for XmlConfig {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        let login_account_fp = self
82            .login_account
83            .as_deref()
84            .map(futu_backend::auth::redact::account_log_fingerprint);
85        let login_pwd = redact_debug_option(&self.login_pwd);
86        let login_pwd_md5 = redact_debug_option(&self.login_pwd_md5);
87
88        f.debug_struct("XmlConfig")
89            .field("login_account_fp", &login_account_fp)
90            .field("login_pwd", &login_pwd)
91            .field("login_pwd_md5", &login_pwd_md5)
92            .field("login_pwd_file", &self.login_pwd_file)
93            .field("login_region", &self.login_region)
94            .field("platform", &self.platform)
95            .field("ip", &self.ip)
96            .field("port", &self.port)
97            .field("websocket_port", &self.websocket_port)
98            .field("telnet_port", &self.telnet_port)
99            .field("rest_port", &self.rest_port)
100            .field("grpc_port", &self.grpc_port)
101            .field("rsa_private_key", &self.rsa_private_key)
102            .field("lang", &self.lang)
103            .field("log_level", &self.log_level)
104            .field("rest_keys_file", &self.rest_keys_file)
105            .field("grpc_keys_file", &self.grpc_keys_file)
106            .field("ws_keys_file", &self.ws_keys_file)
107            .field("audit_log", &self.audit_log)
108            .field("allow_tcp_unauthenticated", &self.allow_tcp_unauthenticated)
109            .field("tz", &self.tz)
110            .finish()
111    }
112}
113
114fn redact_debug_option(value: &Option<String>) -> String {
115    match value {
116        Some(value) => format!("<REDACTED len={}>", value.len()),
117        None => "<NONE>".to_string(),
118    }
119}
120
121/// 从 XML 配置文件读取配置 (兼容 C++ <futu_opend> 格式)
122pub fn load_xml_config(path: &str) -> Result<XmlConfig> {
123    let content = std::fs::read_to_string(path)?;
124    let config: XmlConfig = quick_xml::de::from_str(&content)?;
125    tracing::info!(path, "loaded XML config");
126    Ok(config)
127}
128
129/// 从 TOML 配置文件读取配置 (v1.4.2+;字段与 XmlConfig 一致)
130///
131/// v1.4.102 BUG-006: XmlConfig 加了 `#[serde(deny_unknown_fields)]`, 任何
132/// section ([daemon] 等) / typo / unknown key 直接报 fatal error 而非
133/// silent drop (历史 P1 bug: `[daemon]\nport=12482` daemon log 说 loaded
134/// 但端口实际是 default 11111).
135pub fn load_toml_config(path: &str) -> Result<XmlConfig> {
136    let content = std::fs::read_to_string(path)?;
137    let config: XmlConfig = toml::from_str(&content).map_err(|e| {
138        // v1.4.102 BUG-006: 给 deny_unknown_fields 触发的错加 hint
139        let msg = e.to_string();
140        if msg.contains("unknown field") {
141            anyhow::anyhow!(
142                "TOML config parse error: {msg}\n\
143                 Hint (v1.4.102 BUG-006): TOML 配置必须用 **flat keys**, 不支持 \
144                 [section] 嵌套 (如 `[daemon]\\nport = X` 会触发本 error). \
145                 正确写法: `port = 12482` 直接放在文件顶层 (no [section] header). \
146                 详见 README.md §TOML 配置 / 项目根 deploy/examples/futu-opend.toml 示例."
147            )
148        } else {
149            anyhow::Error::from(e)
150        }
151    })?;
152    // tracing 初始化在 main 里,此时还没 subscriber,用 eprintln
153    eprintln!("[config] loaded TOML config from {path}");
154    Ok(config)
155}
156
157/// 合并后的运行时配置
158pub struct RuntimeConfig {
159    pub ip: String,
160    pub port: u16,
161    pub login_account: Option<String>,
162    pub login_pwd: Option<String>,
163    pub login_pwd_md5: Option<String>,
164    pub login_pwd_file: Option<String>,
165    pub login_region: String,
166    /// v1.4.42 (external reviewer v1.4.40 报告 P3.5 澄清): 标记用户是否显式传了 `--login-region`
167    /// (或在 config 文件里写了)。平台是 moomoo + 用户显式传 region → main() 入口 WARN
168    /// 明示此 flag 对 moomoo 是 noop。避免用户误以为 "layer 2/3 platform IP 按 region 切"。
169    pub login_region_explicit: bool,
170    pub platform: Platform,
171    pub auth_server: String,
172    pub device_id: Option<String>,
173    pub reset_device: bool,
174    pub setup_only: bool,
175    /// v1.4.57 UX-04: 直接传入 SMS 验证码跳过 stdin 交互(Telegram 中继等场景)
176    pub verify_code: Option<String>,
177    pub log_level: String,
178    pub websocket_port: Option<u16>,
179    pub telnet_port: Option<u16>,
180    pub rest_port: Option<u16>,
181    pub grpc_port: Option<u16>,
182    pub rsa_private_key: Option<String>,
183    pub json_log: bool,
184    pub lang: String,
185    // codex 0547 F6 (P3): TOML 安全字段 schema 扩展. main() 之前各自从
186    // `args.*` 拷, 现统一从 RuntimeConfig 读, TOML 配置生效路径打通.
187    pub rest_keys_file: Option<std::path::PathBuf>,
188    pub grpc_keys_file: Option<std::path::PathBuf>,
189    pub ws_keys_file: Option<std::path::PathBuf>,
190    pub audit_log: Option<std::path::PathBuf>,
191    pub allow_tcp_unauthenticated: bool,
192    pub tz: Option<String>,
193}
194
195/// codex 0547 F1+F2 (P2) — explicit user-supplied credential / secret 文件
196/// 读取 helper. 用户**显式**(CLI flag 或 TOML/XML config) 传入路径但读取失败
197/// 时 fail-closed 返 Err. 让 daemon abort 而非 silent fallback.
198///
199/// **范围**: 只覆盖 user-supplied (explicit) credential 路径; auto-detect 路径
200/// 由 caller 自己选 fallback 行为. 当前调用点:
201///
202/// - `rsa_private_key`: `--rsa-private-key` / `[rsa_private_key]` (F1)
203/// - `login_pwd_file`: `--login-pwd-file` / `[login_pwd_file]` (F2; 仍走老
204///   resolve_login_password 7 层链, 但 explicit path 失败 = fatal)
205///
206/// **fail-closed 触发**:
207/// - 文件不存在 / 权限不够 / IO error → Err
208/// - 文件存在但 trim 后为空 → Err (用户传了路径但内容空 = 不一致, 常见
209///   原因: systemd LoadCredential 失败 / Docker secret mount 漏挂 / file
210///   truncated)
211///
212/// **返回**: `Ok(content_after_trim_trailing_whitespace)`, 失败 `Err`.
213///
214/// 调用方对 `Err` 的标准处理是直接 `?` propagate 让 daemon abort.
215pub fn read_explicit_credential_file(field_label: &'static str, path: &str) -> Result<String> {
216    let raw = std::fs::read_to_string(path).map_err(|e| {
217        anyhow::anyhow!(
218            "audit 0547 (P2) fix: failed to read explicit {field_label} from {path}: {e}\n\
219             Explicit credential / secret 路径读失败现在 fail-closed (daemon \
220             abort), 不再 silent fallback. 检查: 文件存在 / 权限 / systemd \
221             LoadCredential= / Docker secret mount. 如要 explicit opt-out fail-closed \
222             behavior, 不传该 flag 即可 (会走 auto-detect / 其他来源)."
223        )
224    })?;
225    let content = raw.trim_end_matches(['\n', '\r', ' ', '\t']).to_string();
226    if content.is_empty() {
227        return Err(anyhow::anyhow!(
228            "audit 0547 (P2) fix: explicit {field_label} at {path} is empty (after \
229             trim). 不允许 (常见原因: secret mount 失败 / file truncated / write \
230             race). 修文件后重启."
231        ));
232    }
233    Ok(content)
234}
235
236/// 合并 CLI args + 配置文件 (CLI 优先)
237///
238/// 配置文件查找顺序:
239///   1. `--config <path>`:TOML(v1.4.2+)
240///   2. `--cfg-file <path>`:XML(兼容 C++ FutuOpenD.xml)
241///   3. 自动检测:同目录 futu-opend.toml → FutuOpenD.xml
242pub fn merge_config(args: Args) -> Result<RuntimeConfig> {
243    // v1.4.102 codex 24 F1 (P1) fix: explicit `--config` / `--cfg-file` 加载
244    // 失败必须 abort, 不再 silent fallback 到 default.
245    //
246    // **历史**: BUG-006 修法只让 `XmlConfig` 加 `deny_unknown_fields`, 但
247    // 实际 daemon 启动路径在这里把任何 parse error catch 后用 default 继续
248    // 跑. 用户写错的 `[daemon]` section 在 `XmlConfig::deserialize` 报错,
249    // 但 daemon 仍以 default 启动 (`port=11111` etc.) — silent ignore 并未
250    // 真正修在 runtime path 上.
251    //
252    // **修法 (codex 24 F1)**: explicit path → `?` 返 error abort daemon.
253    // **auto-detect path 仍 fallback** (用户没显式指定文件 → default 是合理
254    // 行为, 不改).
255    let xml = if let Some(ref path) = args.config {
256        // TOML 显式指定 → fail-closed (BUG-006 真修, codex 24 F1)
257        load_toml_config(path).map_err(|e| {
258            anyhow::anyhow!(
259                "failed to load explicit --config TOML at {path}: {e}\n\
260                 v1.4.102 codex 24 F1 (P1) fix: explicit config 解析失败 daemon abort \
261                 (不再 silent fallback to default)."
262            )
263        })?
264    } else if let Some(ref path) = args.cfg_file {
265        // XML 显式指定 → fail-closed
266        load_xml_config(path).map_err(|e| {
267            anyhow::anyhow!(
268                "failed to load explicit --cfg-file XML at {path}: {e}\n\
269                 v1.4.102 codex 24 F1 (P1) fix: explicit config 解析失败 daemon abort \
270                 (不再 silent fallback to default)."
271            )
272        })?
273    } else {
274        // 自动检测:优先 futu-opend.toml,其次 FutuOpenD.xml
275        let exe_dir = std::env::current_exe()
276            .ok()
277            .and_then(|p| p.parent().map(|d| d.to_path_buf()));
278        if let Some(ref dir) = exe_dir {
279            let toml_path = dir.join("futu-opend.toml");
280            let xml_path = dir.join("FutuOpenD.xml");
281            // v1.4.102 codex 31 F1 (P1) fix: auto-detect config 解析失败也
282            // fail-closed (与 explicit --config / --cfg-file 一致). 之前
283            // unwrap_or_default 让带 [daemon] section 的 auto-loaded TOML
284            // silent fallback to XmlConfig::default(), 与 BUG-006 fatal claim
285            // 不一致.
286            if toml_path.exists() {
287                load_toml_config(&toml_path.to_string_lossy()).map_err(|e| {
288                    anyhow::anyhow!(
289                        "failed to parse auto-detected futu-opend.toml at {}: {e}\n\
290                         v1.4.102 codex 31 F1 (P1): auto-detected config parse \
291                         failure 现在也 fail-closed (与 explicit --config 一致). \
292                         如不希望此文件加载, 删除或重命名即可.",
293                        toml_path.display()
294                    )
295                })?
296            } else if xml_path.exists() {
297                load_xml_config(&xml_path.to_string_lossy()).map_err(|e| {
298                    anyhow::anyhow!(
299                        "failed to parse auto-detected FutuOpenD.xml at {}: {e}\n\
300                         v1.4.102 codex 31 F1 (P1): auto-detected config parse \
301                         failure 现在也 fail-closed.",
302                        xml_path.display()
303                    )
304                })?
305            } else {
306                XmlConfig::default()
307            }
308        } else {
309            XmlConfig::default()
310        }
311    };
312
313    Ok(RuntimeConfig {
314        ip: args.ip.or(xml.ip).unwrap_or_else(|| "0.0.0.0".to_string()),
315        port: args.port.or(xml.port).unwrap_or(11111),
316        login_account: args.login_account.or(xml.login_account),
317        login_pwd: args.login_pwd.or(xml.login_pwd),
318        login_pwd_md5: args.login_pwd_md5.or(xml.login_pwd_md5),
319        login_pwd_file: args.login_pwd_file.or(xml.login_pwd_file),
320        // v1.4.40 #12 fix: LoginRegion enum (gz/sh/hk 三个合法值) → string 给下游 auth
321        // 使用。enum 已保证合法性,这里 as_str() 转回内部约定的 lowercase 形式。
322        // v1.4.42 (P3.5 澄清): login_region_explicit 记录 "是否用户显式传了",
323        // 用于 main() 早期对 moomoo 账户 WARN 这个 flag 对他们 noop。
324        login_region_explicit: args.login_region.is_some() || xml.login_region.is_some(),
325        login_region: args
326            .login_region
327            .or(xml.login_region)
328            .map(|r| r.as_str().to_string())
329            .unwrap_or_else(|| "gz".to_string()),
330        // v1.4.14:auth_server 优先级
331        //   1. `--auth-server <url>` 显式 URL(最高)
332        //   2. `--platform moomoo/futunn` → auth.moomoo.com / auth.futunn.com
333        //   3. XML/TOML 里的 platform 字段
334        //   4. 默认 auth.futunn.com
335        platform: args.platform.or(xml.platform).unwrap_or_default(),
336        auth_server: args.auth_server.unwrap_or_else(|| {
337            args.platform
338                .or(xml.platform)
339                .unwrap_or_default()
340                .auth_server()
341                .to_string()
342        }),
343        log_level: {
344            // codex 0547 F3 (P2) fix: XmlConfig.log_level 改 Option<LogLevel>
345            // 之后, 非法值在 toml::from_str / quick_xml::de::from_str 阶段直接
346            // reject (与 BUG-006 deny_unknown_fields 同语义级别). 这里只把
347            // CLI / config enum 转 lowercase string 给下游使用.
348            //
349            // v1.4.73 BUG-015 老语义 (eprintln + fallback to "info") 已被淘汰 —
350            // explicit user 输入有 typo 必须 daemon abort, 不能 silent
351            // 用 default 跑 (反模式 D / pitfall #45 silent-success).
352            args.log_level
353                .or(xml.log_level)
354                .map(|l| l.as_str().to_string())
355                .unwrap_or_else(|| "info".to_string())
356        },
357        websocket_port: args.websocket_port.or(xml.websocket_port),
358        telnet_port: args.telnet_port.or(xml.telnet_port),
359        rest_port: args.rest_port.or(xml.rest_port),
360        grpc_port: args.grpc_port.or(xml.grpc_port),
361        rsa_private_key: {
362            // codex 0547 F1 (P2) fix: 显式 `--rsa-private-key` / TOML/XML
363            // `rsa_private_key` 路径读失败必须 fail-closed (返 Err 让 daemon
364            // abort), 不再 silent fallback to "no RSA"。systemd `LoadCredential=`
365            // / Docker secret mount 失败时 daemon 仍跑变成无 RSA 模式 = 安全
366            // 配置 silent 失效。统一走 `read_explicit_credential_file` helper.
367            let key_path = args.rsa_private_key.or(xml.rsa_private_key);
368            if let Some(ref path) = key_path {
369                let pem = read_explicit_credential_file("--rsa-private-key", path)?;
370                eprintln!("loaded RSA private key from {path}");
371                Some(pem)
372            } else {
373                None
374            }
375        },
376        device_id: args.device_id,
377        reset_device: args.reset_device,
378        setup_only: args.setup_only,
379        verify_code: args.verify_code,
380        json_log: args.json_log,
381        lang: args.lang.or(xml.lang).unwrap_or_else(|| "chs".to_string()),
382        // codex 0547 F6 (P3): 安全字段 CLI / TOML 双路径合并. CLI 优先, TOML
383        // fallback. allow_tcp_unauthenticated bool 字段无 explicit-false override
384        // (clap derive limit), 与 BUG-006 等价 — explicit-true 一定保留.
385        rest_keys_file: args.rest_keys_file.or(xml.rest_keys_file),
386        grpc_keys_file: args.grpc_keys_file.or(xml.grpc_keys_file),
387        ws_keys_file: args.ws_keys_file.or(xml.ws_keys_file),
388        audit_log: args.audit_log.or(xml.audit_log),
389        allow_tcp_unauthenticated: args.allow_tcp_unauthenticated
390            || xml.allow_tcp_unauthenticated.unwrap_or(false),
391        tz: args.tz.or(xml.tz),
392    })
393}
394
395#[cfg(test)]
396mod tests;