1use anyhow::Result;
4
5use crate::cli::{Args, LogLevel, LoginRegion, Platform};
6
7#[derive(Default, serde::Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct XmlConfig {
24 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 pub login_region: Option<LoginRegion>,
31 pub platform: Option<Platform>,
33 pub ip: Option<String>,
35 #[serde(alias = "api_port")]
36 pub port: Option<u16>,
37 pub websocket_port: Option<u16>,
39 pub telnet_port: Option<u16>,
41 pub rest_port: Option<u16>,
43 pub grpc_port: Option<u16>,
45 pub rsa_private_key: Option<String>,
47 pub lang: Option<String>,
49 pub log_level: Option<LogLevel>,
60 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 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
121pub 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
129pub 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 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 eprintln!("[config] loaded TOML config from {path}");
154 Ok(config)
155}
156
157pub 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 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 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 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
195pub 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
236pub fn merge_config(args: Args) -> Result<RuntimeConfig> {
243 let xml = if let Some(ref path) = args.config {
256 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 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 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 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 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 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 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 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 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;