1use anyhow::Result;
19use std::path::PathBuf;
20use std::sync::Arc;
21
22use crate::cli::Platform;
23use crate::config::RuntimeConfig;
24use crate::crash_log::write_crash_log_file;
25use futu_core::localization::{
26 LanguagePackAutoUpdateOptions, auto_update_language_pack, default_language_pack_cache_root,
27};
28
29pub(super) struct Phase1Out {
32 pub(super) _audit_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
34 pub(super) shared_counters: Arc<futu_auth::RuntimeCounters>,
36 pub(super) listen_addr: String,
38 pub(super) rest_keys_file: Option<std::path::PathBuf>,
40 pub(super) ws_keys_file: Option<std::path::PathBuf>,
41 pub(super) grpc_keys_file: Option<std::path::PathBuf>,
42 pub(super) allow_tcp_unauthenticated: bool,
44}
45
46pub(super) fn is_valid_iana_tz_name(tz: &str) -> bool {
47 if tz == "UTC" {
48 return true;
49 }
50 if tz.is_empty() || tz.len() > 128 || tz.starts_with('/') || tz.ends_with('/') {
56 return false;
57 }
58
59 let mut parts = 0usize;
60 for part in tz.split('/') {
61 parts += 1;
62 if part.is_empty() || part == "." || part == ".." {
63 return false;
64 }
65 if part.contains('.') {
66 return false;
67 }
68 if !part
69 .bytes()
70 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'+'))
71 {
72 return false;
73 }
74 }
75
76 parts >= 2
77}
78
79pub(super) fn apply_pre_runtime_tz(config: &RuntimeConfig) {
80 if let Some(tz) = &config.tz {
81 if !is_valid_iana_tz_name(tz) {
83 eprintln!(
84 "error: --tz 无效 IANA timezone '{tz}'. 示例: Asia/Hong_Kong, America/New_York, UTC"
85 );
86 std::process::exit(2);
87 }
88 unsafe {
93 std::env::set_var("TZ", tz);
94 }
95 eprintln!("ℹ️ TZ set to '{tz}' via --tz flag / TOML tz (v1.4.87 #3 G1)");
96 }
97}
98
99fn language_pack_cache_root(config: &RuntimeConfig) -> PathBuf {
100 config
101 .language_pack_cache_dir
102 .clone()
103 .unwrap_or_else(default_language_pack_cache_root)
104}
105
106pub(super) fn language_pack_auto_update_options(
107 config: &RuntimeConfig,
108) -> LanguagePackAutoUpdateOptions {
109 LanguagePackAutoUpdateOptions {
110 enabled: config.language_pack_auto_update,
111 endpoint: config.language_pack_endpoint.clone(),
112 cache_root: language_pack_cache_root(config),
113 timeout_ms: config.language_pack_update_timeout_ms,
114 lang_filter: None,
115 }
116}
117
118fn spawn_language_pack_auto_update(config: &RuntimeConfig) {
119 let options = language_pack_auto_update_options(config);
120 tokio::spawn(async move {
121 let outcome = auto_update_language_pack(options).await;
122 let status = outcome.state.as_str();
123 match outcome.error.as_deref() {
124 Some(error) => tracing::warn!(
125 target = "language_pack",
126 status,
127 error,
128 pack_version = outcome.pack_version.as_deref().unwrap_or("-"),
129 "language_pack_auto_update"
130 ),
131 None => tracing::info!(
132 target = "language_pack",
133 status,
134 pack_version = outcome.pack_version.as_deref().unwrap_or("-"),
135 "language_pack_auto_update"
136 ),
137 }
138 });
139}
140
141fn port_probe_addr(bind_ip: &str, port: u16) -> std::net::SocketAddr {
142 match bind_ip.parse::<std::net::IpAddr>() {
143 Ok(std::net::IpAddr::V4(ip)) if ip.is_unspecified() => {
144 std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port))
145 }
146 Ok(std::net::IpAddr::V6(ip)) if ip.is_unspecified() => {
147 std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, port))
148 }
149 Ok(ip) => std::net::SocketAddr::new(ip, port),
150 Err(_) => std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port)),
151 }
152}
153
154fn startup_port_flag(surface: &str) -> &'static str {
155 match surface {
156 "FTAPI" => "--port",
157 "REST" => "--rest-port",
158 "gRPC" => "--grpc-port",
159 "WebSocket" => "--websocket-port",
160 "Telnet" => "--telnet-port",
161 _ => "--port",
162 }
163}
164
165pub(super) async fn run_phase1(config: &RuntimeConfig) -> Result<Phase1Out> {
166 let rest_keys_file = config.rest_keys_file.clone();
169 let ws_keys_file = config.ws_keys_file.clone();
170 let grpc_keys_file = config.grpc_keys_file.clone();
171 let audit_log = config.audit_log.clone();
172 let allow_tcp_unauthenticated = config.allow_tcp_unauthenticated;
173
174 for (label, path_opt) in [
182 ("REST", &rest_keys_file),
183 ("gRPC", &grpc_keys_file),
184 ("WS", &ws_keys_file),
185 ] {
186 if let Some(path) = path_opt {
187 match futu_auth::KeyStore::load(path) {
188 Ok(ks) => {
189 tracing::info!(
190 surface = label,
191 path = %path.display(),
192 keys_loaded = ks.len(),
193 "v1.4.104 external report P1-003 (P1): {} keys file pre-validated OK \
194 (broker auth not yet started)",
195 label
196 );
197 }
198 Err(e) => {
199 tracing::error!(
200 surface = label,
201 error = %e,
202 path = %path.display(),
203 "v1.4.104 external report P1-003 (P1): {} keys file pre-validation FAILED — \
204 abort before broker auth / SMS to fail-closed early",
205 label
206 );
207 return Err(anyhow::anyhow!(
208 "v1.4.104 external report P1-003 (P1) fix: {} keys file at {} failed schema \
209 validation: {e}. abort before broker auth / SMS. fix the keys \
210 file then restart.",
211 label,
212 path.display()
213 ));
214 }
215 }
216 }
217 }
218 let _audit_guard = if config.json_log {
225 if audit_log.is_some() {
230 eprintln!(
231 "error: --audit-log and --json-log are mutually exclusive.\n\
232 - --json-log: entire stderr as JSONL (full event stream)\n\
233 - --audit-log: only target=futu_audit events as JSONL to a file\n\
234 choose one. If you need both machine-readable stderr AND a separate audit \
235 file, open an issue — today's layer composition doesn't support it."
236 );
237 std::process::exit(2);
238 }
239 futu_core::log::init_json_logging_with_level(&config.log_level);
240 None
241 } else {
242 match futu_core::log::init_logging_with_audit(&config.log_level, audit_log.as_deref()) {
243 Ok(guard) => {
244 if let (Some(path), Some(_)) = (audit_log.as_ref(), guard.as_ref()) {
245 tracing::info!(
246 path = %path.display(),
247 "audit JSONL logger enabled (target=futu_audit → file)"
248 );
249 }
250 guard
251 }
252 Err(e) => {
253 eprintln!("warning: failed to init audit log: {e}");
254 futu_core::log::init_logging_with_level(&config.log_level);
255 None
256 }
257 }
258 };
259
260 spawn_language_pack_auto_update(config);
261
262 futu_backend::auth::tighten_secret_files_at_startup();
274
275 std::panic::set_hook(Box::new(|info| {
278 let location = info
279 .location()
280 .map(|l| format!("{}:{}", l.file(), l.line()))
281 .unwrap_or_else(|| "<unknown>".to_string());
282 let payload = info
283 .payload()
284 .downcast_ref::<&str>()
285 .copied()
286 .or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
287 .unwrap_or("<non-string panic payload>");
288 let thread = std::thread::current()
289 .name()
290 .unwrap_or("<unnamed>")
291 .to_string();
292 tracing::error!(
293 target: "panic",
294 location = %location,
295 payload = %payload,
296 thread = %thread,
297 "PANIC caught by global hook"
298 );
299 eprintln!("PANIC at {location}: {payload} (thread={thread})");
300 write_crash_log_file(info);
304 #[cfg(not(test))]
314 std::process::exit(101);
315 }));
316
317 futu_auth::metrics::install(std::sync::Arc::new(futu_auth::MetricsRegistry::default()));
320
321 let shared_counters = std::sync::Arc::new(futu_auth::RuntimeCounters::new());
325
326 let listen_addr = format!("{}:{}", config.ip, config.port);
327 tracing::info!(addr = %listen_addr, "starting FutuOpenD Rust Gateway");
328
329 if config.login_region_explicit && matches!(config.platform, Platform::Moomoo) {
340 tracing::warn!(
341 login_region = %config.login_region,
342 platform = "moomoo",
343 "--login-region={region} is a NO-OP for moomoo accounts — flag only \
344 applies to --platform futunn + CN phone-number login. moomoo accounts \
345 route via user_attribution automatically. Observed \"same platform IP \
346 across gz/sh/hk\" is expected behavior. Remove --login-region to silence.",
347 region = config.login_region
348 );
349 }
350
351 {
355 let ports_to_check: Vec<(&str, &str, u16)> =
356 std::iter::once(("FTAPI", config.ip.as_str(), config.port))
357 .chain(config.rest_port.map(|p| ("REST", config.ip.as_str(), p)))
358 .chain(config.grpc_port.map(|p| ("gRPC", config.ip.as_str(), p)))
359 .chain(
360 config
361 .websocket_port
362 .map(|p| ("WebSocket", config.ip.as_str(), p)),
363 )
364 .chain(
365 config
366 .telnet_port
367 .map(|p| ("Telnet", config.telnet_ip.as_str(), p)),
368 )
369 .collect();
370 for (name, bind_ip, port) in &ports_to_check {
371 let addr = port_probe_addr(bind_ip, *port);
372 if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200))
373 .is_ok()
374 {
375 let hint = futu_server::bind_hint::port_conflict_message(
376 name,
377 startup_port_flag(name),
378 &addr.to_string(),
379 );
380 tracing::warn!(
381 name,
382 port,
383 hint = %hint,
384 "startup port conflict probe detected an existing listener"
385 );
386 eprintln!(
387 "warning: startup port conflict probe detected an existing listener: {hint}"
388 );
389 }
390 }
391 }
392
393 Ok(Phase1Out {
394 _audit_guard,
395 shared_counters,
396 listen_addr,
397 rest_keys_file,
398 ws_keys_file,
399 grpc_keys_file,
400 allow_tcp_unauthenticated,
401 })
402}
403
404#[cfg(test)]
405mod tests;