Skip to main content

futu_opend/
config.rs

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