1use std::path::Path;
28
29use tracing::Level;
30
31pub const TARGET: &str = "futu_audit";
33
34pub fn reject(iface: &str, endpoint: &str, key_id: &str, reason: &str) {
36 tracing::event!(
37 target: TARGET,
38 Level::WARN,
39 iface = iface,
40 endpoint = endpoint,
41 key_id = key_id,
42 outcome = "reject",
43 reason = reason,
44 "auth reject"
45 );
46 crate::metrics::bump_auth_event(iface, "reject", key_id);
47 if let Some(rest) = reason.strip_prefix("limit: ") {
49 crate::metrics::bump_limit_reject(iface, key_id, rest);
50 } else if reason.starts_with("rate limit")
51 || reason.starts_with("daily value")
52 || reason.starts_with("order value")
53 {
54 crate::metrics::bump_limit_reject(iface, key_id, reason);
55 }
56}
57
58pub fn allow(iface: &str, endpoint: &str, key_id: &str, scope: Option<&str>) {
60 tracing::event!(
61 target: TARGET,
62 Level::INFO,
63 iface = iface,
64 endpoint = endpoint,
65 key_id = key_id,
66 outcome = "allow",
67 scope = scope.unwrap_or(""),
68 "auth allow"
69 );
70 crate::metrics::bump_auth_event(iface, "allow", key_id);
71}
72
73pub fn trade(
75 iface: &str,
76 tool: &str,
77 key_id: &str,
78 args_hash: &str,
79 outcome: &str,
80 reason: Option<&str>,
81) {
82 tracing::event!(
83 target: TARGET,
84 Level::WARN,
85 iface = iface,
86 endpoint = tool,
87 key_id = key_id,
88 outcome = outcome,
89 args_hash = args_hash,
90 reason = reason.unwrap_or(""),
91 "trade event"
92 );
93 crate::metrics::bump_auth_event(iface, outcome, key_id);
94}
95
96pub fn open_writer(
107 path: &Path,
108) -> std::io::Result<(
109 tracing_appender::non_blocking::NonBlocking,
110 tracing_appender::non_blocking::WorkerGuard,
111)> {
112 let is_dir_hint = path.is_dir()
119 || path.as_os_str().to_string_lossy().ends_with('/')
120 || path.extension().is_none();
121
122 warn_if_world_readable_path(path);
126
127 if is_dir_hint {
128 std::fs::create_dir_all(path)?;
130 tighten_dir_perms(path); tighten_log_files_in_dir(path); let appender = tracing_appender::rolling::daily(path, "futu-audit.log");
139 let wrapped = Mode0600Appender::new(appender, path.to_path_buf());
140 Ok(tracing_appender::non_blocking(wrapped))
141 } else {
142 if let Some(parent) = path.parent()
144 && !parent.as_os_str().is_empty()
145 {
146 std::fs::create_dir_all(parent)?;
147 tighten_dir_perms(parent); }
149 let file = open_file_0600(path)?; Ok(tracing_appender::non_blocking(file))
151 }
152}
153
154pub(crate) fn tighten_dir_perms(path: &Path) {
159 #[cfg(unix)]
160 {
161 use std::os::unix::fs::PermissionsExt;
162 if let Ok(meta) = std::fs::metadata(path) {
163 let cur = meta.permissions().mode() & 0o777;
164 if cur & 0o077 != 0 {
166 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
167 }
168 }
169 }
170 #[cfg(not(unix))]
171 {
172 let _ = path;
173 }
174}
175
176pub(crate) fn open_file_0600(path: &Path) -> std::io::Result<std::fs::File> {
181 let mut opts = std::fs::OpenOptions::new();
182 opts.append(true).create(true);
183 #[cfg(unix)]
184 {
185 use std::os::unix::fs::OpenOptionsExt;
186 opts.mode(0o600);
187 }
188 let file = opts.open(path)?;
189 #[cfg(unix)]
191 {
192 use std::os::unix::fs::PermissionsExt;
193 if let Ok(meta) = file.metadata() {
194 let cur = meta.permissions().mode() & 0o777;
195 if cur & 0o077 != 0 {
196 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
197 }
198 }
199 }
200 Ok(file)
201}
202
203fn warn_if_world_readable_path(path: &Path) {
206 let s = path.as_os_str().to_string_lossy();
207 let world_readable_prefixes = ["/tmp/", "/var/tmp/", "/private/tmp/", "/tmp"];
208 for prefix in &world_readable_prefixes {
209 if s == *prefix || s.starts_with(prefix) {
210 eprintln!(
211 "⚠️ audit log path {s:?} 位于 world-readable 目录 (mode 1777). \
212 audit log 含账户 id + redacted token 占位 + timestamp, 同机其他\n\
213 用户 (包括 agent skill) 可读. 建议改用 ~/.futu-opend-rs/logs/ \
214 或 /var/log/futu/ 等 0700 目录."
215 );
216 break;
217 }
218 }
219}
220
221struct Mode0600Appender {
230 inner: tracing_appender::rolling::RollingFileAppender,
231 dir: std::path::PathBuf,
232 last_tighten: std::sync::atomic::AtomicU64, }
234
235impl Mode0600Appender {
236 fn new(inner: tracing_appender::rolling::RollingFileAppender, dir: std::path::PathBuf) -> Self {
237 Self {
238 inner,
239 dir,
240 last_tighten: std::sync::atomic::AtomicU64::new(0),
241 }
242 }
243
244 fn maybe_tighten(&self) {
246 let now = std::time::SystemTime::now()
247 .duration_since(std::time::UNIX_EPOCH)
248 .map(|d| d.as_secs())
249 .unwrap_or(0);
250 let last = self.last_tighten.load(std::sync::atomic::Ordering::Relaxed);
251 if now > last
252 && self
253 .last_tighten
254 .compare_exchange(
255 last,
256 now,
257 std::sync::atomic::Ordering::Relaxed,
258 std::sync::atomic::Ordering::Relaxed,
259 )
260 .is_ok()
261 {
262 tighten_log_files_in_dir(&self.dir);
263 }
264 }
265}
266
267impl std::io::Write for Mode0600Appender {
268 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
269 let n = self.inner.write(buf)?;
270 self.maybe_tighten();
273 Ok(n)
274 }
275
276 fn flush(&mut self) -> std::io::Result<()> {
277 self.inner.flush()
278 }
279}
280
281pub(crate) fn tighten_log_files_in_dir(dir: &Path) {
284 #[cfg(unix)]
285 {
286 use std::os::unix::fs::PermissionsExt;
287 let Ok(rd) = std::fs::read_dir(dir) else {
288 return;
289 };
290 for entry in rd.flatten() {
291 let p = entry.path();
292 let name = match p.file_name().and_then(|n| n.to_str()) {
293 Some(n) => n,
294 None => continue,
295 };
296 if !name.starts_with("futu-audit.log") {
298 continue;
299 }
300 if let Ok(meta) = std::fs::metadata(&p) {
301 if !meta.is_file() {
302 continue;
303 }
304 let cur = meta.permissions().mode() & 0o777;
305 if cur & 0o077 != 0 {
306 let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600));
307 }
308 }
309 }
310 }
311 #[cfg(not(unix))]
312 {
313 let _ = dir;
314 }
315}
316
317#[cfg(test)]
318mod tests;