sentinel_proxy/
logging.rs

1//! Logging infrastructure for Sentinel proxy
2//!
3//! This module provides structured logging to files for:
4//! - Access logs (request/response data with trace_id)
5//! - Error logs (errors and warnings)
6//! - Audit logs (security events)
7//!
8//! Access log formats supported:
9//! - `json` (default): Structured JSON with all fields
10//! - `combined`: Apache/nginx Combined Log Format with trace_id extension
11
12use 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/// Access log format
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum AccessLogFormat {
26    /// Structured JSON format (default)
27    Json,
28    /// Apache/nginx Combined Log Format with trace_id extension
29    Combined,
30}
31
32/// Access log entry with trace_id for request correlation
33#[derive(Debug, Serialize)]
34pub struct AccessLogEntry {
35    /// Timestamp in RFC3339 format
36    pub timestamp: String,
37    /// Unique trace ID for request correlation
38    pub trace_id: String,
39    /// HTTP method
40    pub method: String,
41    /// Request path
42    pub path: String,
43    /// Query string (if any)
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub query: Option<String>,
46    /// HTTP protocol version
47    pub protocol: String,
48    /// Response status code
49    pub status: u16,
50    /// Response body size in bytes
51    pub body_bytes: u64,
52    /// Request duration in milliseconds
53    pub duration_ms: u64,
54    /// Client IP address
55    pub client_ip: String,
56    /// User-Agent header
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub user_agent: Option<String>,
59    /// Referer header
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub referer: Option<String>,
62    /// Host header
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub host: Option<String>,
65    /// Matched route ID
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub route_id: Option<String>,
68    /// Selected upstream
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub upstream: Option<String>,
71    /// Number of upstream attempts
72    pub upstream_attempts: u32,
73    /// Instance ID of the proxy
74    pub instance_id: String,
75}
76
77impl AccessLogEntry {
78    /// Format the entry as a string based on the specified format
79    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    /// Format as JSON
87    fn format_json(&self) -> String {
88        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
89    }
90
91    /// Format as Combined Log Format with trace_id extension
92    /// Format: client_ip - - [timestamp] "method path?query protocol" status bytes "referer" "user_agent" trace_id duration_ms
93    fn format_combined(&self) -> String {
94        // Parse RFC3339 timestamp to CLF format [day/month/year:hour:min:sec zone]
95        let clf_timestamp = self.format_clf_timestamp();
96
97        // Build request line
98        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        // Escape and format optional fields
105        let referer = self.referer.as_deref().unwrap_or("-");
106        let user_agent = self.user_agent.as_deref().unwrap_or("-");
107
108        // Combined format with trace_id and duration extensions
109        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    /// Convert RFC3339 timestamp to Common Log Format timestamp
124    fn format_clf_timestamp(&self) -> String {
125        // Try to parse and reformat, fallback to original if parsing fails
126        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/// Error log entry
135#[derive(Debug, Serialize)]
136pub struct ErrorLogEntry {
137    /// Timestamp in RFC3339 format
138    pub timestamp: String,
139    /// Trace ID for correlation
140    pub trace_id: String,
141    /// Log level (warn, error)
142    pub level: String,
143    /// Error message
144    pub message: String,
145    /// Route ID if available
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub route_id: Option<String>,
148    /// Upstream if available
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub upstream: Option<String>,
151    /// Error details/context
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub details: Option<String>,
154}
155
156/// Audit log entry for security events
157#[derive(Debug, Serialize)]
158pub struct AuditLogEntry {
159    /// Timestamp in RFC3339 format
160    pub timestamp: String,
161    /// Trace ID for correlation
162    pub trace_id: String,
163    /// Event type (blocked, agent_decision, waf_match, etc.)
164    pub event_type: String,
165    /// HTTP method
166    pub method: String,
167    /// Request path
168    pub path: String,
169    /// Client IP
170    pub client_ip: String,
171    /// Route ID
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub route_id: Option<String>,
174    /// Block reason if blocked
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub reason: Option<String>,
177    /// Agent that made the decision
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub agent_id: Option<String>,
180    /// WAF rule IDs matched
181    #[serde(skip_serializing_if = "Vec::is_empty")]
182    pub rule_ids: Vec<String>,
183    /// Additional tags
184    #[serde(skip_serializing_if = "Vec::is_empty")]
185    pub tags: Vec<String>,
186}
187
188/// Buffered file writer for log files
189struct LogFileWriter {
190    writer: BufWriter<File>,
191}
192
193impl LogFileWriter {
194    fn new(path: &Path, buffer_size: usize) -> Result<Self> {
195        // Create parent directories if they don't exist
196        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
223/// Log manager handling all log file writers
224pub 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    /// Create a new log manager from configuration
234    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    /// Create a disabled log manager (no file logging)
286    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    /// Parse access log format from config string
297    fn parse_access_format(format: &str) -> AccessLogFormat {
298        match format.to_lowercase().as_str() {
299            "combined" | "clf" | "common" => AccessLogFormat::Combined,
300            _ => AccessLogFormat::Json, // Default to JSON
301        }
302    }
303
304    /// Write an access log entry
305    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    /// Write an error log entry
316    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    /// Write an audit log entry
333    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                // Check if we should log this event type
337                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, // Log other event types by default
342                };
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    /// Flush all log buffers
364    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    /// Check if access logging is enabled
383    pub fn access_log_enabled(&self) -> bool {
384        self.access_log.is_some()
385    }
386
387    /// Check if error logging is enabled
388    pub fn error_log_enabled(&self) -> bool {
389        self.error_log.is_some()
390    }
391
392    /// Check if audit logging is enabled
393    pub fn audit_log_enabled(&self) -> bool {
394        self.audit_log.is_some()
395    }
396}
397
398/// Shared log manager that can be passed around
399pub 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        // Check Combined format structure
500        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        ); // Default to JSON
535    }
536}