1use anyhow::{Context, Result};
4
5use crate::cli::{Args, LogLevel, LoginRegion, Platform};
6
7mod runtime_language;
8
9use runtime_language::resolve_process_runtime_language;
10pub(crate) use runtime_language::{RuntimeLanguageSource, app_lang_from_runtime_lang};
11
12#[derive(Default, serde::Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct XmlConfig {
29 pub login_account: Option<String>,
31 pub login_pwd: Option<String>,
32 pub login_pwd_md5: Option<String>,
33 pub login_pwd_file: Option<String>,
34 pub login_region: Option<LoginRegion>,
36 pub platform: Option<Platform>,
38 pub ip: Option<String>,
40 #[serde(alias = "api_port")]
41 pub port: Option<u16>,
42 pub websocket_port: Option<u16>,
44 pub telnet_port: Option<u16>,
46 pub telnet_ip: Option<String>,
47 pub rest_port: Option<u16>,
49 pub rest_tls_cert: Option<std::path::PathBuf>,
50 pub rest_tls_key: Option<std::path::PathBuf>,
51 pub grpc_port: Option<u16>,
53 pub rsa_private_key: Option<String>,
55 pub lang: Option<String>,
57 pub log_level: Option<LogLevel>,
68 pub rest_keys_file: Option<std::path::PathBuf>,
79 pub grpc_keys_file: Option<std::path::PathBuf>,
80 pub ws_keys_file: Option<std::path::PathBuf>,
81 pub audit_log: Option<std::path::PathBuf>,
82 pub allow_tcp_unauthenticated: Option<bool>,
83 pub tz: Option<String>,
85 pub language_pack_auto_update: Option<bool>,
87 pub language_pack_endpoint: Option<String>,
90 pub language_pack_cache_dir: Option<std::path::PathBuf>,
92 pub language_pack_update_timeout_ms: Option<u64>,
94 pub client_sig_proactive_refresh: Option<bool>,
96 pub client_sig_reactive_refresh: Option<bool>,
98 pub history_kline_cloud_sync: Option<bool>,
100}
101
102impl std::fmt::Debug for XmlConfig {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 let login_account_fp = self
105 .login_account
106 .as_deref()
107 .map(futu_backend::auth::redact::account_log_fingerprint);
108 let login_pwd = redact_debug_option(&self.login_pwd);
109 let login_pwd_md5 = redact_debug_option(&self.login_pwd_md5);
110
111 f.debug_struct("XmlConfig")
112 .field("login_account_fp", &login_account_fp)
113 .field("login_pwd", &login_pwd)
114 .field("login_pwd_md5", &login_pwd_md5)
115 .field("login_pwd_file", &self.login_pwd_file)
116 .field("login_region", &self.login_region)
117 .field("platform", &self.platform)
118 .field("ip", &self.ip)
119 .field("port", &self.port)
120 .field("websocket_port", &self.websocket_port)
121 .field("telnet_port", &self.telnet_port)
122 .field("telnet_ip", &self.telnet_ip)
123 .field("rest_port", &self.rest_port)
124 .field("rest_tls_cert", &self.rest_tls_cert)
125 .field("rest_tls_key", &self.rest_tls_key)
126 .field("grpc_port", &self.grpc_port)
127 .field("rsa_private_key", &self.rsa_private_key)
128 .field("lang", &self.lang)
129 .field("log_level", &self.log_level)
130 .field("rest_keys_file", &self.rest_keys_file)
131 .field("grpc_keys_file", &self.grpc_keys_file)
132 .field("ws_keys_file", &self.ws_keys_file)
133 .field("audit_log", &self.audit_log)
134 .field("allow_tcp_unauthenticated", &self.allow_tcp_unauthenticated)
135 .field("tz", &self.tz)
136 .field("language_pack_auto_update", &self.language_pack_auto_update)
137 .field("language_pack_endpoint", &self.language_pack_endpoint)
138 .field("language_pack_cache_dir", &self.language_pack_cache_dir)
139 .field(
140 "language_pack_update_timeout_ms",
141 &self.language_pack_update_timeout_ms,
142 )
143 .field(
144 "client_sig_proactive_refresh",
145 &self.client_sig_proactive_refresh,
146 )
147 .field(
148 "client_sig_reactive_refresh",
149 &self.client_sig_reactive_refresh,
150 )
151 .field("history_kline_cloud_sync", &self.history_kline_cloud_sync)
152 .finish()
153 }
154}
155
156fn redact_debug_option(value: &Option<String>) -> String {
157 match value {
158 Some(value) => format!("<REDACTED len={}>", value.len()),
159 None => "<NONE>".to_string(),
160 }
161}
162
163pub fn load_xml_config(path: &str) -> Result<XmlConfig> {
165 let content = std::fs::read_to_string(path)?;
166 let config: XmlConfig = quick_xml::de::from_str(&content)?;
167 tracing::info!(path, "loaded XML config");
168 Ok(config)
169}
170
171pub fn load_toml_config(path: &str) -> Result<XmlConfig> {
178 let content = std::fs::read_to_string(path)?;
179 let config: XmlConfig = toml::from_str(&content).map_err(|e| {
180 let msg = e.to_string();
182 if msg.contains("unknown field") {
183 anyhow::anyhow!(
184 "TOML config parse error: {msg}\n\
185 Hint (v1.4.102 BUG-006): TOML 配置必须用 **flat keys**, 不支持 \
186 [section] 嵌套 (如 `[daemon]\\nport = X` 会触发本 error). \
187 正确写法: `port = 12482` 直接放在文件顶层 (no [section] header). \
188 详见 README.md §TOML 配置 / 项目根 deploy/examples/futu-opend.toml 示例."
189 )
190 } else {
191 anyhow::Error::from(e)
192 }
193 })?;
194 eprintln!("[config] loaded TOML config from {path}");
196 Ok(config)
197}
198
199pub struct RuntimeConfig {
201 pub ip: String,
202 pub port: u16,
203 pub login_account: Option<String>,
204 pub login_pwd: Option<String>,
205 pub login_pwd_md5: Option<String>,
206 pub login_pwd_file: Option<String>,
207 pub login_region: String,
208 pub login_region_explicit: bool,
212 pub platform: Platform,
213 pub auth_server: String,
214 pub device_id: Option<String>,
215 pub reset_device: bool,
216 pub setup_only: bool,
217 pub verify_code: Option<String>,
219 pub log_level: String,
220 pub websocket_port: Option<u16>,
221 pub telnet_port: Option<u16>,
222 pub telnet_ip: String,
223 pub rest_port: Option<u16>,
224 pub rest_tls_cert: Option<std::path::PathBuf>,
225 pub rest_tls_key: Option<std::path::PathBuf>,
226 pub grpc_port: Option<u16>,
227 pub rsa_private_key: Option<String>,
228 pub json_log: bool,
229 pub lang: String,
230 pub(crate) runtime_language_source: RuntimeLanguageSource,
231 pub rest_keys_file: Option<std::path::PathBuf>,
234 pub grpc_keys_file: Option<std::path::PathBuf>,
235 pub ws_keys_file: Option<std::path::PathBuf>,
236 pub audit_log: Option<std::path::PathBuf>,
237 pub allow_tcp_unauthenticated: bool,
238 pub tz: Option<String>,
239 pub language_pack_auto_update: bool,
240 pub language_pack_endpoint: Option<String>,
241 pub language_pack_cache_dir: Option<std::path::PathBuf>,
242 pub language_pack_update_timeout_ms: u64,
243 pub client_sig_proactive_refresh: bool,
244 pub client_sig_reactive_refresh: bool,
245 pub history_kline_cloud_sync: bool,
246 pub update_check_url: String,
248}
249
250fn clean_optional_string(value: Option<String>) -> Option<String> {
251 value
252 .map(|value| value.trim().to_string())
253 .filter(|value| !value.is_empty())
254}
255
256fn env_optional_string(key: &str) -> Option<String> {
257 std::env::var(key)
258 .ok()
259 .and_then(|value| clean_optional_string(Some(value)))
260}
261
262fn env_optional_path(key: &str) -> Option<std::path::PathBuf> {
263 env_optional_string(key).map(std::path::PathBuf::from)
264}
265
266fn env_optional_u64(key: &str) -> Option<u64> {
267 env_optional_string(key).and_then(|value| value.parse::<u64>().ok())
268}
269
270fn resolve_history_kline_cloud_sync(
271 cli: Option<bool>,
272 config: Option<bool>,
273 env: Option<&str>,
274) -> Result<bool> {
275 if let Some(value) = cli.or(config) {
276 return Ok(value);
277 }
278 let Some(raw) = env else {
279 return Ok(true);
280 };
281 raw.trim().parse::<bool>().map_err(|_| {
282 anyhow::anyhow!("FUTU_HISTORY_KLINE_CLOUD_SYNC must be true or false, got {raw:?}")
283 })
284}
285
286pub fn read_explicit_credential_file(field_label: &'static str, path: &str) -> Result<String> {
307 let raw = std::fs::read_to_string(path).map_err(|e| {
308 anyhow::anyhow!(
309 "audit 0547 (P2) fix: failed to read explicit {field_label} from {path}: {e}\n\
310 Explicit credential / secret 路径读失败现在 fail-closed (daemon \
311 abort), 不再 silent fallback. 检查: 文件存在 / 权限 / systemd \
312 LoadCredential= / Docker secret mount. 如要 explicit opt-out fail-closed \
313 behavior, 不传该 flag 即可 (会走 auto-detect / 其他来源)."
314 )
315 })?;
316 let content = raw.trim_end_matches(['\n', '\r', ' ', '\t']).to_string();
317 if content.is_empty() {
318 return Err(anyhow::anyhow!(
319 "audit 0547 (P2) fix: explicit {field_label} at {path} is empty (after \
320 trim). 不允许 (常见原因: secret mount 失败 / file truncated / write \
321 race). 修文件后重启."
322 ));
323 }
324 Ok(content)
325}
326
327fn resolve_allow_tcp_unauthenticated(
334 cli_allow: bool,
335 cli_deny: bool,
336 configured: Option<bool>,
337) -> bool {
338 debug_assert!(!(cli_allow && cli_deny), "clap rejects conflicting flags");
339 if cli_deny {
340 false
341 } else if cli_allow {
342 true
343 } else {
344 configured.unwrap_or(false)
345 }
346}
347
348pub fn merge_config(args: Args) -> Result<RuntimeConfig> {
349 let xml = if let Some(ref path) = args.config {
362 load_toml_config(path).map_err(|e| {
364 anyhow::anyhow!(
365 "failed to load explicit --config TOML at {path}: {e}\n\
366 v1.4.102 codex 24 F1 (P1) fix: explicit config 解析失败 daemon abort \
367 (不再 silent fallback to default)."
368 )
369 })?
370 } else if let Some(ref path) = args.cfg_file {
371 load_xml_config(path).map_err(|e| {
373 anyhow::anyhow!(
374 "failed to load explicit --cfg-file XML at {path}: {e}\n\
375 v1.4.102 codex 24 F1 (P1) fix: explicit config 解析失败 daemon abort \
376 (不再 silent fallback to default)."
377 )
378 })?
379 } else {
380 let exe_dir = std::env::current_exe()
382 .ok()
383 .and_then(|p| p.parent().map(|d| d.to_path_buf()));
384 if let Some(ref dir) = exe_dir {
385 let toml_path = dir.join("futu-opend.toml");
386 let xml_path = dir.join("FutuOpenD.xml");
387 if toml_path.exists() {
393 load_toml_config(&toml_path.to_string_lossy()).map_err(|e| {
394 anyhow::anyhow!(
395 "failed to parse auto-detected futu-opend.toml at {}: {e}\n\
396 v1.4.102 codex 31 F1 (P1): auto-detected config parse \
397 failure 现在也 fail-closed (与 explicit --config 一致). \
398 如不希望此文件加载, 删除或重命名即可.",
399 toml_path.display()
400 )
401 })?
402 } else if xml_path.exists() {
403 load_xml_config(&xml_path.to_string_lossy()).map_err(|e| {
404 anyhow::anyhow!(
405 "failed to parse auto-detected FutuOpenD.xml at {}: {e}\n\
406 v1.4.102 codex 31 F1 (P1): auto-detected config parse \
407 failure 现在也 fail-closed.",
408 xml_path.display()
409 )
410 })?
411 } else {
412 XmlConfig::default()
413 }
414 } else {
415 XmlConfig::default()
416 }
417 };
418
419 let runtime_language =
420 resolve_process_runtime_language(args.lang.as_deref(), xml.lang.as_deref());
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 rest_tls_cert: args.rest_tls_cert.or(xml.rest_tls_cert),
479 rest_tls_key: args.rest_tls_key.or(xml.rest_tls_key),
480 grpc_port: args.grpc_port.or(xml.grpc_port),
481 rsa_private_key: {
482 let key_path = args.rsa_private_key.or(xml.rsa_private_key);
488 if let Some(ref path) = key_path {
489 let pem = read_explicit_credential_file("--rsa-private-key", path)?;
490 futu_net::encrypt::validate_rsa_private_key(&pem)
496 .map_err(anyhow::Error::new)
497 .with_context(|| {
498 format!("invalid --rsa-private-key material from configured path {path}")
499 })?;
500 eprintln!("loaded RSA private key from {path}");
501 Some(pem)
502 } else {
503 None
504 }
505 },
506 device_id: args.device_id,
507 reset_device: args.reset_device,
508 setup_only: args.setup_only,
509 verify_code: args.verify_code,
510 json_log: args.json_log,
511 lang: runtime_language.lang,
512 runtime_language_source: runtime_language.source,
513 language_pack_auto_update: args
514 .language_pack_auto_update
515 .or(xml.language_pack_auto_update)
516 .unwrap_or(true),
517 language_pack_endpoint: clean_optional_string(args.language_pack_endpoint)
518 .or_else(|| clean_optional_string(xml.language_pack_endpoint))
519 .or_else(|| env_optional_string("FUTU_LANGUAGE_PACK_ENDPOINT")),
520 language_pack_cache_dir: args
521 .language_pack_cache_dir
522 .or(xml.language_pack_cache_dir)
523 .or_else(|| env_optional_path("FUTU_LANGUAGE_PACK_CACHE_DIR")),
524 language_pack_update_timeout_ms: args
525 .language_pack_update_timeout_ms
526 .or(xml.language_pack_update_timeout_ms)
527 .or_else(|| env_optional_u64("FUTU_LANGUAGE_PACK_UPDATE_TIMEOUT_MS"))
528 .unwrap_or(3000),
529 rest_keys_file: args.rest_keys_file.or(xml.rest_keys_file),
532 grpc_keys_file: args.grpc_keys_file.or(xml.grpc_keys_file),
533 ws_keys_file: args.ws_keys_file.or(xml.ws_keys_file),
534 audit_log: args.audit_log.or(xml.audit_log),
535 allow_tcp_unauthenticated: resolve_allow_tcp_unauthenticated(
536 args.allow_tcp_unauthenticated,
537 args.no_allow_tcp_unauthenticated,
538 xml.allow_tcp_unauthenticated,
539 ),
540 tz: args.tz.or(xml.tz),
541 client_sig_proactive_refresh: args.client_sig_proactive_refresh
545 || xml.client_sig_proactive_refresh.unwrap_or(false),
546 client_sig_reactive_refresh: args.client_sig_reactive_refresh
547 || xml.client_sig_reactive_refresh.unwrap_or(false),
548 history_kline_cloud_sync: resolve_history_kline_cloud_sync(
549 args.history_kline_cloud_sync,
550 xml.history_kline_cloud_sync,
551 std::env::var("FUTU_HISTORY_KLINE_CLOUD_SYNC")
552 .ok()
553 .as_deref(),
554 )?,
555 update_check_url: futu_core::update_check::resolve_update_check_url(None),
556 })
557}
558
559#[cfg(test)]
560mod tests;