1use std::path::Path;
2use std::sync::OnceLock;
3
4use tracing_subscriber::{
5 EnvFilter, Layer, filter::filter_fn, fmt, layer::SubscriberExt, reload, util::SubscriberInitExt,
6};
7
8#[derive(Clone, Copy, Debug)]
14pub struct LocalRfc3339Timer;
15
16impl fmt::time::FormatTime for LocalRfc3339Timer {
17 fn format_time(&self, w: &mut fmt::format::Writer<'_>) -> std::fmt::Result {
18 write!(
19 w,
20 "{}",
21 chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
22 )
23 }
24}
25
26static GLOBAL_RELOAD_HANDLE: OnceLock<reload::Handle<EnvFilter, tracing_subscriber::Registry>> =
37 OnceLock::new();
38
39fn map_remote_level_to_env_filter(level: &str) -> Option<&'static str> {
44 match level.to_lowercase().as_str() {
45 "no" => Some("off"),
46 "debug" => Some("debug"),
47 "info" => Some("info"),
48 "warning" => Some("warn"),
49 "error" => Some("error"),
50 "fatal" => Some("error"), _ => None,
52 }
53}
54
55pub fn set_runtime_log_level(level: &str) -> Result<String, String> {
70 let mapped = match map_remote_level_to_env_filter(level) {
71 Some(s) => s,
72 None => return Err("level invalid".to_string()),
73 };
74
75 let handle = match GLOBAL_RELOAD_HANDLE.get() {
76 Some(h) => h,
77 None => return Err("reload handle not initialized".to_string()),
78 };
79
80 let new_filter = EnvFilter::try_new(mapped)
81 .map_err(|e| format!("EnvFilter parse failed for '{mapped}': {e}"))?;
82
83 handle
84 .reload(new_filter)
85 .map_err(|e| format!("reload failed: {e}"))?;
86
87 Ok(mapped.to_string())
88}
89
90pub fn is_runtime_reload_available() -> bool {
93 GLOBAL_RELOAD_HANDLE.get().is_some()
94}
95
96pub fn init_logging_with_level(level: &str) {
103 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level));
104 let (filter_layer, reload_handle) = reload::Layer::new(filter);
105
106 let fmt_layer = fmt::layer()
107 .with_timer(LocalRfc3339Timer)
108 .with_target(true)
109 .with_thread_ids(true)
110 .with_file(true)
111 .with_line_number(true)
112 .with_writer(std::io::stderr);
113
114 let registry = tracing_subscriber::registry()
115 .with(filter_layer)
116 .with(fmt_layer);
117 if registry.try_init().is_ok() {
118 let _ = GLOBAL_RELOAD_HANDLE.set(reload_handle);
120 }
121}
122
123pub fn init_logging() {
126 init_logging_with_level("info");
127}
128
129pub fn init_json_logging_with_level(level: &str) {
133 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level));
134 let (filter_layer, reload_handle) = reload::Layer::new(filter);
135
136 let fmt_layer = fmt::layer()
137 .json()
138 .with_timer(LocalRfc3339Timer)
139 .with_target(true)
140 .with_thread_ids(true);
141
142 let registry = tracing_subscriber::registry()
143 .with(filter_layer)
144 .with(fmt_layer);
145 if registry.try_init().is_ok() {
146 let _ = GLOBAL_RELOAD_HANDLE.set(reload_handle);
147 }
148}
149
150pub fn init_json_logging() {
152 init_json_logging_with_level("info");
153}
154
155pub fn init_logging_with_audit(
163 level: &str,
164 audit_path: Option<&Path>,
165) -> std::io::Result<Option<tracing_appender::non_blocking::WorkerGuard>> {
166 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level));
167 let (filter_layer, reload_handle) = reload::Layer::new(filter);
169
170 let fmt_layer = fmt::layer()
171 .with_timer(LocalRfc3339Timer)
172 .with_target(true)
173 .with_thread_ids(true)
174 .with_file(true)
175 .with_line_number(true)
176 .with_writer(std::io::stderr);
177
178 let registry = tracing_subscriber::registry()
179 .with(filter_layer)
180 .with(fmt_layer);
181
182 let init_ok = if let Some(path) = audit_path {
183 let (writer, guard) = open_audit_writer(path)?;
185 let audit_layer = fmt::layer()
186 .json()
187 .flatten_event(true)
188 .with_current_span(false)
189 .with_span_list(false)
190 .with_target(true)
191 .with_writer(writer)
192 .with_filter(filter_fn(|meta| meta.target() == "futu_audit"));
193 let init_ok = registry.with(audit_layer).try_init().is_ok();
194 if init_ok {
195 let _ = GLOBAL_RELOAD_HANDLE.set(reload_handle);
196 }
197 return Ok(if init_ok { Some(guard) } else { None });
198 } else {
199 let ok = registry.try_init().is_ok();
200 if ok {
201 let _ = GLOBAL_RELOAD_HANDLE.set(reload_handle);
202 }
203 ok
204 };
205 let _ = init_ok;
206 Ok(None)
207}
208
209fn open_audit_writer(
216 path: &Path,
217) -> std::io::Result<(
218 tracing_appender::non_blocking::NonBlocking,
219 tracing_appender::non_blocking::WorkerGuard,
220)> {
221 let is_dir_hint = path.is_dir()
222 || path.as_os_str().to_string_lossy().ends_with('/')
223 || path.extension().is_none();
224
225 warn_if_world_readable_path(path);
227
228 if is_dir_hint {
229 std::fs::create_dir_all(path)?;
230 tighten_dir_perms(path); tighten_log_files_in_dir(path);
234 let appender = tracing_appender::rolling::daily(path, "futu-audit.log");
235 let wrapped = Mode0600Appender::new(appender, path.to_path_buf());
236 Ok(tracing_appender::non_blocking(wrapped))
237 } else {
238 if let Some(parent) = path.parent()
239 && !parent.as_os_str().is_empty()
240 {
241 std::fs::create_dir_all(parent)?;
242 tighten_dir_perms(parent); }
244 let file = open_file_0600(path)?; Ok(tracing_appender::non_blocking(file))
246 }
247}
248
249fn tighten_dir_perms(path: &Path) {
252 #[cfg(unix)]
253 {
254 use std::os::unix::fs::PermissionsExt;
255 if let Ok(meta) = std::fs::metadata(path) {
256 let cur = meta.permissions().mode() & 0o777;
257 if cur & 0o077 != 0 {
258 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
259 }
260 }
261 }
262 #[cfg(not(unix))]
263 {
264 let _ = path;
265 }
266}
267
268fn open_file_0600(path: &Path) -> std::io::Result<std::fs::File> {
270 let mut opts = std::fs::OpenOptions::new();
271 opts.append(true).create(true);
272 #[cfg(unix)]
273 {
274 use std::os::unix::fs::OpenOptionsExt;
275 opts.mode(0o600);
276 }
277 let file = opts.open(path)?;
278 #[cfg(unix)]
279 {
280 use std::os::unix::fs::PermissionsExt;
281 if let Ok(meta) = file.metadata() {
282 let cur = meta.permissions().mode() & 0o777;
283 if cur & 0o077 != 0 {
284 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
285 }
286 }
287 }
288 Ok(file)
289}
290
291fn warn_if_world_readable_path(path: &Path) {
293 let s = path.as_os_str().to_string_lossy();
294 let world_readable_prefixes = ["/tmp/", "/var/tmp/", "/private/tmp/", "/tmp"];
295 for prefix in &world_readable_prefixes {
296 if s == *prefix || s.starts_with(prefix) {
297 eprintln!(
298 "⚠️ audit log path {s:?} 位于 world-readable 目录 (mode 1777). \
299 audit log 含账户 id + redacted token 占位 + timestamp, 同机其他\n\
300 用户 (包括 agent skill) 可读. 建议改用 ~/.futu-opend-rs/logs/ \
301 或 /var/log/futu/ 等 0700 目录."
302 );
303 break;
304 }
305 }
306}
307
308struct Mode0600Appender {
311 inner: tracing_appender::rolling::RollingFileAppender,
312 dir: std::path::PathBuf,
313 last_tighten: std::sync::atomic::AtomicU64,
314}
315
316impl Mode0600Appender {
317 fn new(inner: tracing_appender::rolling::RollingFileAppender, dir: std::path::PathBuf) -> Self {
318 Self {
319 inner,
320 dir,
321 last_tighten: std::sync::atomic::AtomicU64::new(0),
322 }
323 }
324
325 fn maybe_tighten(&self) {
326 let now = std::time::SystemTime::now()
327 .duration_since(std::time::UNIX_EPOCH)
328 .map(|d| d.as_secs())
329 .unwrap_or(0);
330 let last = self.last_tighten.load(std::sync::atomic::Ordering::Relaxed);
331 if now > last
332 && self
333 .last_tighten
334 .compare_exchange(
335 last,
336 now,
337 std::sync::atomic::Ordering::Relaxed,
338 std::sync::atomic::Ordering::Relaxed,
339 )
340 .is_ok()
341 {
342 tighten_log_files_in_dir(&self.dir);
343 }
344 }
345}
346
347impl std::io::Write for Mode0600Appender {
348 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
349 let n = self.inner.write(buf)?;
350 self.maybe_tighten();
351 Ok(n)
352 }
353
354 fn flush(&mut self) -> std::io::Result<()> {
355 self.inner.flush()
356 }
357}
358
359fn tighten_log_files_in_dir(dir: &Path) {
361 #[cfg(unix)]
362 {
363 use std::os::unix::fs::PermissionsExt;
364 let Ok(rd) = std::fs::read_dir(dir) else {
365 return;
366 };
367 for entry in rd.flatten() {
368 let p = entry.path();
369 let name = match p.file_name().and_then(|n| n.to_str()) {
370 Some(n) => n,
371 None => continue,
372 };
373 if !name.starts_with("futu-audit.log") {
374 continue;
375 }
376 if let Ok(meta) = std::fs::metadata(&p) {
377 if !meta.is_file() {
378 continue;
379 }
380 let cur = meta.permissions().mode() & 0o777;
381 if cur & 0o077 != 0 {
382 let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600));
383 }
384 }
385 }
386 }
387 #[cfg(not(unix))]
388 {
389 let _ = dir;
390 }
391}