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}