1use anyhow::{Context, Result};
13use parking_lot::Mutex;
14use serde::Serialize;
15use std::fs::{File, OpenOptions};
16use std::io::{BufWriter, Write};
17use std::path::Path;
18use std::sync::Arc;
19use tracing::{error, warn};
20
21use sentinel_config::{AuditLogConfig, LoggingConfig};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum AccessLogFormat {
26 Json,
28 Combined,
30}
31
32#[derive(Debug, Serialize)]
34pub struct AccessLogEntry {
35 pub timestamp: String,
37 pub trace_id: String,
39 pub method: String,
41 pub path: String,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub query: Option<String>,
46 pub protocol: String,
48 pub status: u16,
50 pub body_bytes: u64,
52 pub duration_ms: u64,
54 pub client_ip: String,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub user_agent: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub referer: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub host: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub route_id: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub upstream: Option<String>,
71 pub upstream_attempts: u32,
73 pub instance_id: String,
75}
76
77impl AccessLogEntry {
78 pub fn format(&self, format: AccessLogFormat) -> String {
80 match format {
81 AccessLogFormat::Json => self.format_json(),
82 AccessLogFormat::Combined => self.format_combined(),
83 }
84 }
85
86 fn format_json(&self) -> String {
88 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
89 }
90
91 fn format_combined(&self) -> String {
94 let clf_timestamp = self.format_clf_timestamp();
96
97 let request_line = if let Some(ref query) = self.query {
99 format!("{} {}?{} {}", self.method, self.path, query, self.protocol)
100 } else {
101 format!("{} {} {}", self.method, self.path, self.protocol)
102 };
103
104 let referer = self.referer.as_deref().unwrap_or("-");
106 let user_agent = self.user_agent.as_deref().unwrap_or("-");
107
108 format!(
110 "{} - - [{}] \"{}\" {} {} \"{}\" \"{}\" {} {}ms",
111 self.client_ip,
112 clf_timestamp,
113 request_line,
114 self.status,
115 self.body_bytes,
116 referer,
117 user_agent,
118 self.trace_id,
119 self.duration_ms
120 )
121 }
122
123 fn format_clf_timestamp(&self) -> String {
125 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&self.timestamp) {
127 dt.format("%d/%b/%Y:%H:%M:%S %z").to_string()
128 } else {
129 self.timestamp.clone()
130 }
131 }
132}
133
134#[derive(Debug, Serialize)]
136pub struct ErrorLogEntry {
137 pub timestamp: String,
139 pub trace_id: String,
141 pub level: String,
143 pub message: String,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub route_id: Option<String>,
148 #[serde(skip_serializing_if = "Option::is_none")]
150 pub upstream: Option<String>,
151 #[serde(skip_serializing_if = "Option::is_none")]
153 pub details: Option<String>,
154}
155
156#[derive(Debug, Serialize)]
158pub struct AuditLogEntry {
159 pub timestamp: String,
161 pub trace_id: String,
163 pub event_type: String,
165 pub method: String,
167 pub path: String,
169 pub client_ip: String,
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub route_id: Option<String>,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub reason: Option<String>,
177 #[serde(skip_serializing_if = "Option::is_none")]
179 pub agent_id: Option<String>,
180 #[serde(skip_serializing_if = "Vec::is_empty")]
182 pub rule_ids: Vec<String>,
183 #[serde(skip_serializing_if = "Vec::is_empty")]
185 pub tags: Vec<String>,
186}
187
188struct LogFileWriter {
190 writer: BufWriter<File>,
191}
192
193impl LogFileWriter {
194 fn new(path: &Path, buffer_size: usize) -> Result<Self> {
195 if let Some(parent) = path.parent() {
197 std::fs::create_dir_all(parent)
198 .with_context(|| format!("Failed to create log directory: {:?}", parent))?;
199 }
200
201 let file = OpenOptions::new()
202 .create(true)
203 .append(true)
204 .open(path)
205 .with_context(|| format!("Failed to open log file: {:?}", path))?;
206
207 Ok(Self {
208 writer: BufWriter::with_capacity(buffer_size, file),
209 })
210 }
211
212 fn write_line(&mut self, line: &str) -> Result<()> {
213 writeln!(self.writer, "{}", line)?;
214 Ok(())
215 }
216
217 fn flush(&mut self) -> Result<()> {
218 self.writer.flush()?;
219 Ok(())
220 }
221}
222
223pub struct LogManager {
225 access_log: Option<Mutex<LogFileWriter>>,
226 access_log_format: AccessLogFormat,
227 error_log: Option<Mutex<LogFileWriter>>,
228 audit_log: Option<Mutex<LogFileWriter>>,
229 audit_config: Option<AuditLogConfig>,
230}
231
232impl LogManager {
233 pub fn new(config: &LoggingConfig) -> Result<Self> {
235 let (access_log, access_log_format) = if let Some(ref access_config) = config.access_log {
236 if access_config.enabled {
237 let format = Self::parse_access_format(&access_config.format);
238 let writer = Mutex::new(LogFileWriter::new(
239 &access_config.file,
240 access_config.buffer_size,
241 )?);
242 (Some(writer), format)
243 } else {
244 (None, AccessLogFormat::Json)
245 }
246 } else {
247 (None, AccessLogFormat::Json)
248 };
249
250 let error_log = if let Some(ref error_config) = config.error_log {
251 if error_config.enabled {
252 Some(Mutex::new(LogFileWriter::new(
253 &error_config.file,
254 error_config.buffer_size,
255 )?))
256 } else {
257 None
258 }
259 } else {
260 None
261 };
262
263 let audit_log = if let Some(ref audit_config) = config.audit_log {
264 if audit_config.enabled {
265 Some(Mutex::new(LogFileWriter::new(
266 &audit_config.file,
267 audit_config.buffer_size,
268 )?))
269 } else {
270 None
271 }
272 } else {
273 None
274 };
275
276 Ok(Self {
277 access_log,
278 access_log_format,
279 error_log,
280 audit_log,
281 audit_config: config.audit_log.clone(),
282 })
283 }
284
285 pub fn disabled() -> Self {
287 Self {
288 access_log: None,
289 access_log_format: AccessLogFormat::Json,
290 error_log: None,
291 audit_log: None,
292 audit_config: None,
293 }
294 }
295
296 fn parse_access_format(format: &str) -> AccessLogFormat {
298 match format.to_lowercase().as_str() {
299 "combined" | "clf" | "common" => AccessLogFormat::Combined,
300 _ => AccessLogFormat::Json, }
302 }
303
304 pub fn log_access(&self, entry: &AccessLogEntry) {
306 if let Some(ref writer) = self.access_log {
307 let formatted = entry.format(self.access_log_format);
308 let mut guard = writer.lock();
309 if let Err(e) = guard.write_line(&formatted) {
310 error!("Failed to write access log: {}", e);
311 }
312 }
313 }
314
315 pub fn log_error(&self, entry: &ErrorLogEntry) {
317 if let Some(ref writer) = self.error_log {
318 match serde_json::to_string(entry) {
319 Ok(json) => {
320 let mut guard = writer.lock();
321 if let Err(e) = guard.write_line(&json) {
322 error!("Failed to write error log: {}", e);
323 }
324 }
325 Err(e) => {
326 error!("Failed to serialize error log entry: {}", e);
327 }
328 }
329 }
330 }
331
332 pub fn log_audit(&self, entry: &AuditLogEntry) {
334 if let Some(ref writer) = self.audit_log {
335 if let Some(ref config) = self.audit_config {
336 let should_log = match entry.event_type.as_str() {
338 "blocked" => config.log_blocked,
339 "agent_decision" => config.log_agent_decisions,
340 "waf_match" | "waf_block" => config.log_waf_events,
341 _ => true, };
343
344 if !should_log {
345 return;
346 }
347 }
348
349 match serde_json::to_string(entry) {
350 Ok(json) => {
351 let mut guard = writer.lock();
352 if let Err(e) = guard.write_line(&json) {
353 error!("Failed to write audit log: {}", e);
354 }
355 }
356 Err(e) => {
357 error!("Failed to serialize audit log entry: {}", e);
358 }
359 }
360 }
361 }
362
363 pub fn flush(&self) {
365 if let Some(ref writer) = self.access_log {
366 if let Err(e) = writer.lock().flush() {
367 warn!("Failed to flush access log: {}", e);
368 }
369 }
370 if let Some(ref writer) = self.error_log {
371 if let Err(e) = writer.lock().flush() {
372 warn!("Failed to flush error log: {}", e);
373 }
374 }
375 if let Some(ref writer) = self.audit_log {
376 if let Err(e) = writer.lock().flush() {
377 warn!("Failed to flush audit log: {}", e);
378 }
379 }
380 }
381
382 pub fn access_log_enabled(&self) -> bool {
384 self.access_log.is_some()
385 }
386
387 pub fn error_log_enabled(&self) -> bool {
389 self.error_log.is_some()
390 }
391
392 pub fn audit_log_enabled(&self) -> bool {
394 self.audit_log.is_some()
395 }
396}
397
398pub type SharedLogManager = Arc<LogManager>;
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use sentinel_config::{AccessLogConfig, ErrorLogConfig};
405 use tempfile::tempdir;
406
407 #[test]
408 fn test_access_log_entry_serialization() {
409 let entry = AccessLogEntry {
410 timestamp: "2024-01-01T00:00:00Z".to_string(),
411 trace_id: "abc123".to_string(),
412 method: "GET".to_string(),
413 path: "/api/users".to_string(),
414 query: Some("page=1".to_string()),
415 protocol: "HTTP/1.1".to_string(),
416 status: 200,
417 body_bytes: 1024,
418 duration_ms: 50,
419 client_ip: "192.168.1.1".to_string(),
420 user_agent: Some("Mozilla/5.0".to_string()),
421 referer: None,
422 host: Some("example.com".to_string()),
423 route_id: Some("api-route".to_string()),
424 upstream: Some("backend-1".to_string()),
425 upstream_attempts: 1,
426 instance_id: "instance-1".to_string(),
427 };
428
429 let json = serde_json::to_string(&entry).unwrap();
430 assert!(json.contains("\"trace_id\":\"abc123\""));
431 assert!(json.contains("\"status\":200"));
432 }
433
434 #[test]
435 fn test_log_manager_creation() {
436 let dir = tempdir().unwrap();
437 let access_log_path = dir.path().join("access.log");
438 let error_log_path = dir.path().join("error.log");
439 let audit_log_path = dir.path().join("audit.log");
440
441 let config = LoggingConfig {
442 level: "info".to_string(),
443 format: "json".to_string(),
444 timestamps: true,
445 file: None,
446 access_log: Some(AccessLogConfig {
447 enabled: true,
448 file: access_log_path.clone(),
449 format: "json".to_string(),
450 buffer_size: 8192,
451 include_trace_id: true,
452 }),
453 error_log: Some(ErrorLogConfig {
454 enabled: true,
455 file: error_log_path.clone(),
456 level: "warn".to_string(),
457 buffer_size: 8192,
458 }),
459 audit_log: Some(AuditLogConfig {
460 enabled: true,
461 file: audit_log_path.clone(),
462 buffer_size: 8192,
463 log_blocked: true,
464 log_agent_decisions: true,
465 log_waf_events: true,
466 }),
467 };
468
469 let manager = LogManager::new(&config).unwrap();
470 assert!(manager.access_log_enabled());
471 assert!(manager.error_log_enabled());
472 assert!(manager.audit_log_enabled());
473 }
474
475 #[test]
476 fn test_access_log_combined_format() {
477 let entry = AccessLogEntry {
478 timestamp: "2024-01-15T10:30:00+00:00".to_string(),
479 trace_id: "trace-abc123".to_string(),
480 method: "GET".to_string(),
481 path: "/api/users".to_string(),
482 query: Some("page=1".to_string()),
483 protocol: "HTTP/1.1".to_string(),
484 status: 200,
485 body_bytes: 1024,
486 duration_ms: 50,
487 client_ip: "192.168.1.1".to_string(),
488 user_agent: Some("Mozilla/5.0".to_string()),
489 referer: Some("https://example.com/".to_string()),
490 host: Some("api.example.com".to_string()),
491 route_id: Some("api-route".to_string()),
492 upstream: Some("backend-1".to_string()),
493 upstream_attempts: 1,
494 instance_id: "instance-1".to_string(),
495 };
496
497 let combined = entry.format(AccessLogFormat::Combined);
498
499 assert!(combined.starts_with("192.168.1.1 - - ["));
501 assert!(combined.contains("\"GET /api/users?page=1 HTTP/1.1\""));
502 assert!(combined.contains(" 200 1024 "));
503 assert!(combined.contains("\"https://example.com/\""));
504 assert!(combined.contains("\"Mozilla/5.0\""));
505 assert!(combined.contains("trace-abc123"));
506 assert!(combined.ends_with("50ms"));
507 }
508
509 #[test]
510 fn test_access_log_format_parsing() {
511 assert_eq!(
512 LogManager::parse_access_format("json"),
513 AccessLogFormat::Json
514 );
515 assert_eq!(
516 LogManager::parse_access_format("JSON"),
517 AccessLogFormat::Json
518 );
519 assert_eq!(
520 LogManager::parse_access_format("combined"),
521 AccessLogFormat::Combined
522 );
523 assert_eq!(
524 LogManager::parse_access_format("COMBINED"),
525 AccessLogFormat::Combined
526 );
527 assert_eq!(
528 LogManager::parse_access_format("clf"),
529 AccessLogFormat::Combined
530 );
531 assert_eq!(
532 LogManager::parse_access_format("unknown"),
533 AccessLogFormat::Json
534 ); }
536}