1use anyhow::Result;
20use std::path::PathBuf;
21use std::sync::Arc;
22
23use crate::cli::Platform;
24use crate::config::RuntimeConfig;
25use crate::crash_log::write_crash_log_file;
26use futu_core::localization::{
27 LanguagePackAutoUpdateOptions, auto_update_language_pack,
28 bind_runtime_language_pack_cache_root, default_language_pack_cache_root,
29};
30
31pub(super) struct Phase1Out {
34 pub(super) _audit_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
36 pub(super) shared_counters: Arc<futu_auth::RuntimeCounters>,
38 pub(super) listen_addr: String,
40 pub(super) rest_keys_file: Option<std::path::PathBuf>,
42 pub(super) rest_key_store: Option<Arc<futu_auth::KeyStore>>,
43 pub(super) rest_transport: futu_rest::server::RestTransport,
44 pub(super) ws_keys_file: Option<std::path::PathBuf>,
45 pub(super) ws_key_store: Option<Arc<futu_auth::KeyStore>>,
46 pub(super) grpc_keys_file: Option<std::path::PathBuf>,
47 pub(super) grpc_key_store: Option<Arc<futu_auth::KeyStore>>,
48 pub(super) allow_tcp_unauthenticated: bool,
50}
51
52pub(super) fn is_valid_iana_tz_name(tz: &str) -> bool {
53 if tz == "UTC" {
54 return true;
55 }
56 if tz.is_empty() || tz.len() > 128 || tz.starts_with('/') || tz.ends_with('/') {
62 return false;
63 }
64
65 let mut parts = 0usize;
66 for part in tz.split('/') {
67 parts += 1;
68 if part.is_empty() || part == "." || part == ".." {
69 return false;
70 }
71 if part.contains('.') {
72 return false;
73 }
74 if !part
75 .bytes()
76 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'+'))
77 {
78 return false;
79 }
80 }
81
82 parts >= 2
83}
84
85pub(super) fn apply_pre_runtime_tz(config: &RuntimeConfig) {
86 if let Some(tz) = &config.tz {
87 if !is_valid_iana_tz_name(tz) {
89 eprintln!(
90 "error: --tz 无效 IANA timezone '{tz}'. 示例: Asia/Hong_Kong, America/New_York, UTC"
91 );
92 std::process::exit(2);
93 }
94 unsafe {
99 std::env::set_var("TZ", tz);
100 }
101 eprintln!("ℹ️ TZ set to '{tz}' via --tz flag / TOML tz (v1.4.87 #3 G1)");
102 }
103}
104
105fn language_pack_cache_root(config: &RuntimeConfig) -> PathBuf {
106 config
107 .language_pack_cache_dir
108 .clone()
109 .unwrap_or_else(default_language_pack_cache_root)
110}
111
112pub(super) fn language_pack_auto_update_options(
113 config: &RuntimeConfig,
114) -> LanguagePackAutoUpdateOptions {
115 LanguagePackAutoUpdateOptions {
116 enabled: config.language_pack_auto_update,
117 endpoint: config.language_pack_endpoint.clone(),
118 cache_root: language_pack_cache_root(config),
119 timeout_ms: config.language_pack_update_timeout_ms,
120 lang_filter: None,
121 }
122}
123
124fn spawn_language_pack_auto_update(config: &RuntimeConfig) -> Result<()> {
125 let options = language_pack_auto_update_options(config);
126 bind_runtime_language_pack_cache_root(options.cache_root.clone())
127 .map_err(anyhow::Error::msg)?;
128 tokio::spawn(async move {
129 let outcome = auto_update_language_pack(options).await;
130 let status = outcome.state.as_str();
131 match outcome.error.as_deref() {
132 Some(error) => tracing::warn!(
133 target = "language_pack",
134 status,
135 error,
136 pack_version = outcome.pack_version.as_deref().unwrap_or("-"),
137 "language_pack_auto_update"
138 ),
139 None => tracing::info!(
140 target = "language_pack",
141 status,
142 pack_version = outcome.pack_version.as_deref().unwrap_or("-"),
143 "language_pack_auto_update"
144 ),
145 }
146 });
147 Ok(())
148}
149
150fn runtime_language_log_facts(config: &RuntimeConfig) -> (&str, &'static str) {
151 (
152 config.lang.as_str(),
153 config.runtime_language_source.as_str(),
154 )
155}
156
157fn log_runtime_language(config: &RuntimeConfig) {
158 let (lang, source) = runtime_language_log_facts(config);
159 tracing::info!(
160 target: "startup",
161 lang = %lang,
162 source = %source,
163 "runtime_language_resolved"
164 );
165}
166
167fn port_probe_addr(bind_ip: &str, port: u16) -> std::net::SocketAddr {
168 match bind_ip.parse::<std::net::IpAddr>() {
169 Ok(std::net::IpAddr::V4(ip)) if ip.is_unspecified() => {
170 std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port))
171 }
172 Ok(std::net::IpAddr::V6(ip)) if ip.is_unspecified() => {
173 std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, port))
174 }
175 Ok(ip) => std::net::SocketAddr::new(ip, port),
176 Err(_) => std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port)),
177 }
178}
179
180fn startup_port_flag(surface: &str) -> &'static str {
181 match surface {
182 "FTAPI" => "--port",
183 "REST" => "--rest-port",
184 "gRPC" => "--grpc-port",
185 "WebSocket" => "--websocket-port",
186 "Telnet" => "--telnet-port",
187 _ => "--port",
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192struct StartupPortProbe<'a> {
193 surface: &'static str,
194 bind_ip: &'a str,
195 port: u16,
196}
197
198pub(super) fn ftapi_listener_enabled(config: &RuntimeConfig) -> bool {
199 if config.setup_only {
200 return false;
201 }
202 let any_keys_configured = config.rest_keys_file.is_some()
203 || config.grpc_keys_file.is_some()
204 || config.ws_keys_file.is_some();
205 !any_keys_configured || config.allow_tcp_unauthenticated
206}
207
208fn startup_port_probes(config: &RuntimeConfig) -> Vec<StartupPortProbe<'_>> {
209 if config.setup_only {
210 return Vec::new();
211 }
212
213 let mut probes = Vec::new();
214 if ftapi_listener_enabled(config) {
215 probes.push(StartupPortProbe {
216 surface: "FTAPI",
217 bind_ip: &config.ip,
218 port: config.port,
219 });
220 }
221 for (surface, port) in [
222 ("REST", config.rest_port),
223 ("gRPC", config.grpc_port),
224 ("WebSocket", config.websocket_port),
225 ] {
226 if let Some(port) = port {
227 probes.push(StartupPortProbe {
228 surface,
229 bind_ip: &config.ip,
230 port,
231 });
232 }
233 }
234 if let Some(port) = config.telnet_port {
235 probes.push(StartupPortProbe {
236 surface: "Telnet",
237 bind_ip: &config.telnet_ip,
238 port,
239 });
240 }
241 probes
242}
243
244fn startup_port_conflict_hint(surface: &str, addr: &str) -> String {
245 futu_server::bind_hint::port_conflict_message(surface, startup_port_flag(surface), addr)
246}
247
248pub(super) async fn prepare_rest_transport(
249 rest_port: Option<u16>,
250 rest_keys_file: Option<&std::path::Path>,
251 cert_path: Option<&std::path::Path>,
252 key_path: Option<&std::path::Path>,
253) -> Result<futu_rest::server::RestTransport> {
254 use futu_rest::server::{RestTlsConfig, RestTransport};
255
256 match (rest_port, cert_path, key_path) {
257 (None, None, None) => Ok(RestTransport::Plaintext),
258 (None, _, _) => Err(anyhow::anyhow!(
259 "--rest-tls-cert/--rest-tls-key require --rest-port"
260 )),
261 (Some(_), Some(cert), Some(key)) => RestTlsConfig::from_pem_files(cert, key)
262 .await
263 .map(RestTransport::Tls)
264 .map_err(|error| {
265 anyhow::Error::new(error)
266 .context("invalid REST TLS certificate/private-key configuration")
267 }),
268 (Some(_), None, None) if rest_keys_file.is_some() => Err(anyhow::anyhow!(
269 "--rest-keys-file requires native REST TLS: configure both \
270 --rest-tls-cert and --rest-tls-key"
271 )),
272 (Some(_), None, None) => Ok(RestTransport::Plaintext),
273 (Some(_), _, _) => Err(anyhow::anyhow!(
274 "--rest-tls-cert and --rest-tls-key must be configured together"
275 )),
276 }
277}
278
279pub(super) async fn run_phase1(config: &RuntimeConfig) -> Result<Phase1Out> {
280 let rest_keys_file = config.rest_keys_file.clone();
283 let ws_keys_file = config.ws_keys_file.clone();
284 let grpc_keys_file = config.grpc_keys_file.clone();
285 let audit_log = config.audit_log.clone();
286 let allow_tcp_unauthenticated = config.allow_tcp_unauthenticated;
287 let rest_transport = prepare_rest_transport(
288 config.rest_port,
289 rest_keys_file.as_deref(),
290 config.rest_tls_cert.as_deref(),
291 config.rest_tls_key.as_deref(),
292 )
293 .await?;
294
295 let preload = |label: &'static str,
303 path: Option<&std::path::Path>|
304 -> Result<Option<Arc<futu_auth::KeyStore>>> {
305 let Some(path) = path else {
306 return Ok(None);
307 };
308 let store = futu_auth::KeyStore::load(path).map_err(|error| {
309 anyhow::anyhow!(
310 "{label} keys file at {} failed schema validation before broker auth / SMS: \
311 {error}. fix the keys file then restart.",
312 path.display()
313 )
314 })?;
315 tracing::info!(
316 surface = label,
317 path = %path.display(),
318 keys_loaded = store.len(),
319 "keys file parsed and retained before listener bind"
320 );
321 Ok(Some(Arc::new(store)))
322 };
323 let rest_key_store = preload("REST", rest_keys_file.as_deref())?;
324 let grpc_key_store = preload("gRPC", grpc_keys_file.as_deref())?;
325 let ws_key_store = preload("WS", ws_keys_file.as_deref())?;
326 let _audit_guard = if config.json_log {
333 if audit_log.is_some() {
338 eprintln!(
339 "error: --audit-log and --json-log are mutually exclusive.\n\
340 - --json-log: entire stderr as JSONL (full event stream)\n\
341 - --audit-log: only target=futu_audit events as JSONL to a file\n\
342 choose one. If you need both machine-readable stderr AND a separate audit \
343 file, open an issue — today's layer composition doesn't support it."
344 );
345 std::process::exit(2);
346 }
347 futu_core::log::init_json_logging_with_level(&config.log_level);
348 None
349 } else {
350 match futu_core::log::init_logging_with_audit(&config.log_level, audit_log.as_deref()) {
351 Ok(guard) => {
352 if let (Some(path), Some(_)) = (audit_log.as_ref(), guard.as_ref()) {
353 tracing::info!(
354 path = %path.display(),
355 "audit JSONL logger enabled (target=futu_audit → file)"
356 );
357 }
358 guard
359 }
360 Err(error) => {
361 let context = audit_log.as_ref().map_or_else(
362 || "failed to initialize daemon logging".to_owned(),
363 |path| {
364 format!(
365 "failed to initialize explicitly configured audit log at {}",
366 path.display()
367 )
368 },
369 );
370 return Err(anyhow::Error::new(error).context(context));
371 }
372 }
373 };
374
375 log_runtime_language(config);
376 spawn_language_pack_auto_update(config)?;
377
378 futu_backend::auth::tighten_secret_files_at_startup();
390
391 std::panic::set_hook(Box::new(|info| {
394 let location = info
395 .location()
396 .map(|l| format!("{}:{}", l.file(), l.line()))
397 .unwrap_or_else(|| "<unknown>".to_string());
398 let payload = info
399 .payload()
400 .downcast_ref::<&str>()
401 .copied()
402 .or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
403 .unwrap_or("<non-string panic payload>");
404 let thread = std::thread::current()
405 .name()
406 .unwrap_or("<unnamed>")
407 .to_string();
408 tracing::error!(
409 target: "panic",
410 location = %location,
411 payload = %payload,
412 thread = %thread,
413 "PANIC caught by global hook"
414 );
415 eprintln!("PANIC at {location}: {payload} (thread={thread})");
416 write_crash_log_file(info);
420 #[cfg(not(test))]
430 std::process::exit(101);
431 }));
432
433 futu_auth::metrics::install(std::sync::Arc::new(futu_auth::MetricsRegistry::default()));
436
437 let shared_counters = std::sync::Arc::new(futu_auth::RuntimeCounters::new());
441
442 let listen_addr = format!("{}:{}", config.ip, config.port);
443 tracing::info!(addr = %listen_addr, "starting FutuOpenD Rust Gateway");
444
445 if config.login_region_explicit && matches!(config.platform, Platform::Moomoo) {
456 tracing::warn!(
457 login_region = %config.login_region,
458 platform = "moomoo",
459 "--login-region={region} is a NO-OP for moomoo accounts — flag only \
460 applies to --platform futunn + CN phone-number login. moomoo accounts \
461 route via user_attribution automatically. Observed \"same platform IP \
462 across gz/sh/hk\" is expected behavior. Remove --login-region to silence.",
463 region = config.login_region
464 );
465 }
466
467 {
473 for probe in startup_port_probes(config) {
474 let addr = port_probe_addr(probe.bind_ip, probe.port);
475 if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200))
476 .is_ok()
477 {
478 let hint = startup_port_conflict_hint(probe.surface, &addr.to_string());
479 return Err(anyhow::anyhow!(
480 "startup port conflict probe detected an existing listener: {hint}"
481 ));
482 }
483 }
484 }
485
486 Ok(Phase1Out {
487 _audit_guard,
488 shared_counters,
489 listen_addr,
490 rest_keys_file,
491 rest_key_store,
492 rest_transport,
493 ws_keys_file,
494 ws_key_store,
495 grpc_keys_file,
496 grpc_key_store,
497 allow_tcp_unauthenticated,
498 })
499}
500
501#[cfg(test)]
502mod tests;