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 telnet_ip: Option<String>,
42 pub rest_port: Option<u16>,
44 pub grpc_port: Option<u16>,
46 pub rsa_private_key: Option<String>,
48 pub lang: Option<String>,
50 pub log_level: Option<LogLevel>,
61 pub rest_keys_file: Option<std::path::PathBuf>,
72 pub grpc_keys_file: Option<std::path::PathBuf>,
73 pub ws_keys_file: Option<std::path::PathBuf>,
74 pub audit_log: Option<std::path::PathBuf>,
75 pub allow_tcp_unauthenticated: Option<bool>,
76 pub tz: Option<String>,
78 pub language_pack_auto_update: Option<bool>,
80 pub language_pack_endpoint: Option<String>,
82 pub language_pack_cache_dir: Option<std::path::PathBuf>,
84 pub language_pack_update_timeout_ms: Option<u64>,
86 pub client_sig_proactive_refresh: Option<bool>,
88 pub client_sig_reactive_refresh: Option<bool>,
90}
91
92impl std::fmt::Debug for XmlConfig {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 let login_account_fp = self
95 .login_account
96 .as_deref()
97 .map(futu_backend::auth::redact::account_log_fingerprint);
98 let login_pwd = redact_debug_option(&self.login_pwd);
99 let login_pwd_md5 = redact_debug_option(&self.login_pwd_md5);
100
101 f.debug_struct("XmlConfig")
102 .field("login_account_fp", &login_account_fp)
103 .field("login_pwd", &login_pwd)
104 .field("login_pwd_md5", &login_pwd_md5)
105 .field("login_pwd_file", &self.login_pwd_file)
106 .field("login_region", &self.login_region)
107 .field("platform", &self.platform)
108 .field("ip", &self.ip)
109 .field("port", &self.port)
110 .field("websocket_port", &self.websocket_port)
111 .field("telnet_port", &self.telnet_port)
112 .field("telnet_ip", &self.telnet_ip)
113 .field("rest_port", &self.rest_port)
114 .field("grpc_port", &self.grpc_port)
115 .field("rsa_private_key", &self.rsa_private_key)
116 .field("lang", &self.lang)
117 .field("log_level", &self.log_level)
118 .field("rest_keys_file", &self.rest_keys_file)
119 .field("grpc_keys_file", &self.grpc_keys_file)
120 .field("ws_keys_file", &self.ws_keys_file)
121 .field("audit_log", &self.audit_log)
122 .field("allow_tcp_unauthenticated", &self.allow_tcp_unauthenticated)
123 .field("tz", &self.tz)
124 .field("language_pack_auto_update", &self.language_pack_auto_update)
125 .field("language_pack_endpoint", &self.language_pack_endpoint)
126 .field("language_pack_cache_dir", &self.language_pack_cache_dir)
127 .field(
128 "language_pack_update_timeout_ms",
129 &self.language_pack_update_timeout_ms,
130 )
131 .field(
132 "client_sig_proactive_refresh",
133 &self.client_sig_proactive_refresh,
134 )
135 .field(
136 "client_sig_reactive_refresh",
137 &self.client_sig_reactive_refresh,
138 )
139 .finish()
140 }
141}
142
143fn redact_debug_option(value: &Option<String>) -> String {
144 match value {
145 Some(value) => format!("<REDACTED len={}>", value.len()),
146 None => "<NONE>".to_string(),
147 }
148}
149
150pub fn load_xml_config(path: &str) -> Result<XmlConfig> {
152 let content = std::fs::read_to_string(path)?;
153 let config: XmlConfig = quick_xml::de::from_str(&content)?;
154 tracing::info!(path, "loaded XML config");
155 Ok(config)
156}
157
158pub fn load_toml_config(path: &str) -> Result<XmlConfig> {
165 let content = std::fs::read_to_string(path)?;
166 let config: XmlConfig = toml::from_str(&content).map_err(|e| {
167 let msg = e.to_string();
169 if msg.contains("unknown field") {
170 anyhow::anyhow!(
171 "TOML config parse error: {msg}\n\
172 Hint (v1.4.102 BUG-006): TOML 配置必须用 **flat keys**, 不支持 \
173 [section] 嵌套 (如 `[daemon]\\nport = X` 会触发本 error). \
174 正确写法: `port = 12482` 直接放在文件顶层 (no [section] header). \
175 详见 README.md §TOML 配置 / 项目根 deploy/examples/futu-opend.toml 示例."
176 )
177 } else {
178 anyhow::Error::from(e)
179 }
180 })?;
181 eprintln!("[config] loaded TOML config from {path}");
183 Ok(config)
184}
185
186pub struct RuntimeConfig {
188 pub ip: String,
189 pub port: u16,
190 pub login_account: Option<String>,
191 pub login_pwd: Option<String>,
192 pub login_pwd_md5: Option<String>,
193 pub login_pwd_file: Option<String>,
194 pub login_region: String,
195 pub login_region_explicit: bool,
199 pub platform: Platform,
200 pub auth_server: String,
201 pub device_id: Option<String>,
202 pub reset_device: bool,
203 pub setup_only: bool,
204 pub verify_code: Option<String>,
206 pub log_level: String,
207 pub websocket_port: Option<u16>,
208 pub telnet_port: Option<u16>,
209 pub telnet_ip: String,
210 pub rest_port: Option<u16>,
211 pub grpc_port: Option<u16>,
212 pub rsa_private_key: Option<String>,
213 pub json_log: bool,
214 pub lang: String,
215 pub rest_keys_file: Option<std::path::PathBuf>,
218 pub grpc_keys_file: Option<std::path::PathBuf>,
219 pub ws_keys_file: Option<std::path::PathBuf>,
220 pub audit_log: Option<std::path::PathBuf>,
221 pub allow_tcp_unauthenticated: bool,
222 pub tz: Option<String>,
223 pub language_pack_auto_update: bool,
224 pub language_pack_endpoint: Option<String>,
225 pub language_pack_cache_dir: Option<std::path::PathBuf>,
226 pub language_pack_update_timeout_ms: u64,
227 pub client_sig_proactive_refresh: bool,
228 pub client_sig_reactive_refresh: bool,
229}
230
231pub(crate) fn normalize_runtime_lang(raw: &str) -> &'static str {
232 let lang = raw.trim().to_ascii_lowercase().replace('-', "_");
233 match lang.as_str() {
234 "chs" | "zh" | "zh_cn" | "zh_sg" | "zh_hans" | "zh_hans_cn" | "zh_hans_sg" => "chs",
235 "cht" | "zh_tw" | "zh_hk" | "zh_mo" | "zh_hant" | "zh_hant_tw" | "zh_hant_hk"
236 | "zh_hant_mo" => "cht",
237 "en" | "en_us" | "en_gb" | "en_au" | "en_ca" => "en",
238 _ => "en",
239 }
240}
241
242pub(crate) fn runtime_lang_from_locale(locale: Option<&str>) -> &'static str {
243 let Some(locale) = locale.map(str::trim).filter(|s| !s.is_empty()) else {
244 return "en";
245 };
246 let primary = locale
247 .split([':', '.', '@'])
248 .next()
249 .unwrap_or(locale)
250 .replace('-', "_")
251 .to_ascii_lowercase();
252 normalize_runtime_lang(&primary)
253}
254
255pub(crate) fn app_lang_from_runtime_lang(raw: &str) -> i32 {
256 match normalize_runtime_lang(raw) {
257 "chs" => 0,
258 "cht" => 1,
259 "en" => 2,
260 _ => 2,
261 }
262}
263
264fn system_default_lang() -> String {
265 for key in ["LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"] {
266 if let Some(lang) = std::env::var_os(key)
267 .and_then(|value| value.into_string().ok())
268 .map(|value| runtime_lang_from_locale(Some(&value)))
269 .filter(|lang| !lang.is_empty())
270 {
271 return lang.to_string();
272 }
273 }
274 "en".to_string()
275}
276
277fn merge_runtime_lang(cli_lang: Option<String>, config_lang: Option<String>) -> String {
278 cli_lang
279 .or(config_lang)
280 .map(|lang| normalize_runtime_lang(&lang).to_string())
281 .unwrap_or_else(system_default_lang)
282}
283
284fn clean_optional_string(value: Option<String>) -> Option<String> {
285 value
286 .map(|value| value.trim().to_string())
287 .filter(|value| !value.is_empty())
288}
289
290fn env_optional_string(key: &str) -> Option<String> {
291 std::env::var(key)
292 .ok()
293 .and_then(|value| clean_optional_string(Some(value)))
294}
295
296fn env_optional_path(key: &str) -> Option<std::path::PathBuf> {
297 env_optional_string(key).map(std::path::PathBuf::from)
298}
299
300fn env_optional_u64(key: &str) -> Option<u64> {
301 env_optional_string(key).and_then(|value| value.parse::<u64>().ok())
302}
303
304pub fn read_explicit_credential_file(field_label: &'static str, path: &str) -> Result<String> {
325 let raw = std::fs::read_to_string(path).map_err(|e| {
326 anyhow::anyhow!(
327 "audit 0547 (P2) fix: failed to read explicit {field_label} from {path}: {e}\n\
328 Explicit credential / secret 路径读失败现在 fail-closed (daemon \
329 abort), 不再 silent fallback. 检查: 文件存在 / 权限 / systemd \
330 LoadCredential= / Docker secret mount. 如要 explicit opt-out fail-closed \
331 behavior, 不传该 flag 即可 (会走 auto-detect / 其他来源)."
332 )
333 })?;
334 let content = raw.trim_end_matches(['\n', '\r', ' ', '\t']).to_string();
335 if content.is_empty() {
336 return Err(anyhow::anyhow!(
337 "audit 0547 (P2) fix: explicit {field_label} at {path} is empty (after \
338 trim). 不允许 (常见原因: secret mount 失败 / file truncated / write \
339 race). 修文件后重启."
340 ));
341 }
342 Ok(content)
343}
344
345pub fn merge_config(args: Args) -> Result<RuntimeConfig> {
352 let xml = if let Some(ref path) = args.config {
365 load_toml_config(path).map_err(|e| {
367 anyhow::anyhow!(
368 "failed to load explicit --config TOML at {path}: {e}\n\
369 v1.4.102 codex 24 F1 (P1) fix: explicit config 解析失败 daemon abort \
370 (不再 silent fallback to default)."
371 )
372 })?
373 } else if let Some(ref path) = args.cfg_file {
374 load_xml_config(path).map_err(|e| {
376 anyhow::anyhow!(
377 "failed to load explicit --cfg-file XML at {path}: {e}\n\
378 v1.4.102 codex 24 F1 (P1) fix: explicit config 解析失败 daemon abort \
379 (不再 silent fallback to default)."
380 )
381 })?
382 } else {
383 let exe_dir = std::env::current_exe()
385 .ok()
386 .and_then(|p| p.parent().map(|d| d.to_path_buf()));
387 if let Some(ref dir) = exe_dir {
388 let toml_path = dir.join("futu-opend.toml");
389 let xml_path = dir.join("FutuOpenD.xml");
390 if toml_path.exists() {
396 load_toml_config(&toml_path.to_string_lossy()).map_err(|e| {
397 anyhow::anyhow!(
398 "failed to parse auto-detected futu-opend.toml at {}: {e}\n\
399 v1.4.102 codex 31 F1 (P1): auto-detected config parse \
400 failure 现在也 fail-closed (与 explicit --config 一致). \
401 如不希望此文件加载, 删除或重命名即可.",
402 toml_path.display()
403 )
404 })?
405 } else if xml_path.exists() {
406 load_xml_config(&xml_path.to_string_lossy()).map_err(|e| {
407 anyhow::anyhow!(
408 "failed to parse auto-detected FutuOpenD.xml at {}: {e}\n\
409 v1.4.102 codex 31 F1 (P1): auto-detected config parse \
410 failure 现在也 fail-closed.",
411 xml_path.display()
412 )
413 })?
414 } else {
415 XmlConfig::default()
416 }
417 } else {
418 XmlConfig::default()
419 }
420 };
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 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 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 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 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 grpc_port: args.grpc_port.or(xml.grpc_port),
479 rsa_private_key: {
480 let key_path = args.rsa_private_key.or(xml.rsa_private_key);
486 if let Some(ref path) = key_path {
487 let pem = read_explicit_credential_file("--rsa-private-key", path)?;
488 eprintln!("loaded RSA private key from {path}");
489 Some(pem)
490 } else {
491 None
492 }
493 },
494 device_id: args.device_id,
495 reset_device: args.reset_device,
496 setup_only: args.setup_only,
497 verify_code: args.verify_code,
498 json_log: args.json_log,
499 lang: merge_runtime_lang(args.lang, xml.lang),
500 language_pack_auto_update: args
501 .language_pack_auto_update
502 .or(xml.language_pack_auto_update)
503 .unwrap_or(true),
504 language_pack_endpoint: clean_optional_string(args.language_pack_endpoint)
505 .or_else(|| clean_optional_string(xml.language_pack_endpoint))
506 .or_else(|| env_optional_string("FUTU_LANGUAGE_PACK_ENDPOINT")),
507 language_pack_cache_dir: args
508 .language_pack_cache_dir
509 .or(xml.language_pack_cache_dir)
510 .or_else(|| env_optional_path("FUTU_LANGUAGE_PACK_CACHE_DIR")),
511 language_pack_update_timeout_ms: args
512 .language_pack_update_timeout_ms
513 .or(xml.language_pack_update_timeout_ms)
514 .or_else(|| env_optional_u64("FUTU_LANGUAGE_PACK_UPDATE_TIMEOUT_MS"))
515 .unwrap_or(3000),
516 rest_keys_file: args.rest_keys_file.or(xml.rest_keys_file),
520 grpc_keys_file: args.grpc_keys_file.or(xml.grpc_keys_file),
521 ws_keys_file: args.ws_keys_file.or(xml.ws_keys_file),
522 audit_log: args.audit_log.or(xml.audit_log),
523 allow_tcp_unauthenticated: args.allow_tcp_unauthenticated
524 || xml.allow_tcp_unauthenticated.unwrap_or(false),
525 tz: args.tz.or(xml.tz),
526 client_sig_proactive_refresh: args.client_sig_proactive_refresh
530 || xml.client_sig_proactive_refresh.unwrap_or(false),
531 client_sig_reactive_refresh: args.client_sig_reactive_refresh
532 || xml.client_sig_reactive_refresh.unwrap_or(false),
533 })
534}
535
536#[cfg(test)]
537mod tests;