package logger import ( "fmt" "io" "os" "path/filepath" "strings" "unicode/utf8" "github.com/rs/zerolog" "gopkg.in/natefinch/lumberjack.v2" ) // Level represents log level type Level = zerolog.Level const ( LevelDebug = zerolog.DebugLevel LevelInfo = zerolog.InfoLevel LevelWarn = zerolog.WarnLevel LevelError = zerolog.ErrorLevel LevelFatal = zerolog.FatalLevel ) // Logger wraps zerolog.Logger type Logger struct { zl zerolog.Logger // audit is a second, file-only logger used for security audit records. // It writes JSON lines to AuditFile so audit data is not buried in // debug/operational output and can be retained independently. audit zerolog.Logger auditEnabled bool auditPath string // "" when the separate audit file is disabled closers []io.Closer } // Config holds logger configuration type Config struct { // Level is the minimum log level Level Level // Console enables console output Console bool // File is the log file path (empty to disable file logging) File string // MaxSize is the max size in MB before rotation MaxSize int // MaxBackups is the max number of old log files to keep MaxBackups int // MaxAge is the max days to keep old log files MaxAge int // Compress enables gzip compression for rotated files Compress bool // AuditFile is the dedicated security-audit log path ("" disables the // separate audit file; audit records still go to the main log). Audit // records are JSON lines with audit=true, event=, and contextual // fields such as actor/role/ip/device_id. AuditFile string // AuditMaxSize / AuditMaxBackups / AuditMaxAge tune audit rotation. // Zero values pick 100 MB / 20 files / 90 days. AuditMaxSize int AuditMaxBackups int AuditMaxAge int // AuditCompress enables gzip compression for rotated audit files. AuditCompress bool } // DefaultConfig returns default configuration func DefaultConfig() Config { return Config{ Level: LevelInfo, Console: true, File: "", MaxSize: 100, MaxBackups: 3, MaxAge: 30, Compress: true, } } // New creates a new logger with config func New(cfg Config) *Logger { var writers []io.Writer var closers []io.Closer // Console output with color if cfg.Console { consoleWriter := zerolog.ConsoleWriter{ Out: os.Stdout, TimeFormat: "2006-01-02 15:04:05", } writers = append(writers, consoleWriter) } // File output with rotation if cfg.File != "" { // Ensure directory exists dir := filepath.Dir(cfg.File) if dir != "" && dir != "." { _ = os.MkdirAll(dir, 0755) } fileWriter := &lumberjack.Logger{ Filename: cfg.File, MaxSize: cfg.MaxSize, MaxBackups: cfg.MaxBackups, MaxAge: cfg.MaxAge, Compress: cfg.Compress, LocalTime: true, } writers = append(writers, fileWriter) closers = append(closers, fileWriter) } // Combine writers var writer io.Writer if len(writers) == 0 { writer = os.Stdout } else if len(writers) == 1 { writer = writers[0] } else { writer = zerolog.MultiLevelWriter(writers...) } // Create logger zl := zerolog.New(writer). Level(cfg.Level). With(). Timestamp(). Logger() l := &Logger{zl: zl, closers: closers} if cfg.AuditFile != "" { dir := filepath.Dir(cfg.AuditFile) if dir != "" && dir != "." { _ = os.MkdirAll(dir, 0755) } maxSize := cfg.AuditMaxSize if maxSize <= 0 { maxSize = 100 } maxBackups := cfg.AuditMaxBackups if maxBackups <= 0 { maxBackups = 20 } maxAge := cfg.AuditMaxAge if maxAge <= 0 { maxAge = 90 } auditWriter := &lumberjack.Logger{ Filename: cfg.AuditFile, MaxSize: maxSize, MaxBackups: maxBackups, MaxAge: maxAge, Compress: cfg.AuditCompress, LocalTime: true, } l.audit = zerolog.New(auditWriter). Level(zerolog.InfoLevel). With(). Timestamp(). Logger() l.auditEnabled = true l.auditPath = cfg.AuditFile l.closers = append(l.closers, auditWriter) } return l } // Close releases any file handles owned by the logger. Safe to call multiple // times. Loggers derived with WithPrefix share the base logger's files but do // not own them, so call Close on the logger returned by New. func (l *Logger) Close() error { if l == nil { return nil } for _, c := range l.closers { _ = c.Close() } l.closers = nil return nil } // WithPrefix returns a new logger with a prefix field func (l *Logger) WithPrefix(prefix string) *Logger { n := &Logger{zl: l.zl.With().Str("module", prefix).Logger(), auditPath: l.auditPath} if l.auditEnabled { n.audit = l.audit.With().Str("module", prefix).Logger() n.auditEnabled = true } return n } // AuditPath returns the path of the dedicated audit log file, or "" when the // separate audit file is disabled (audit records then only reach the main log). // Derived loggers (WithPrefix) report the same path. func (l *Logger) AuditPath() string { if l == nil { return "" } return l.auditPath } // Debug logs a debug message func (l *Logger) Debug(format string, args ...interface{}) { l.zl.Debug().Msgf(format, args...) } // Info logs an info message func (l *Logger) Info(format string, args ...interface{}) { l.zl.Info().Msgf(format, args...) } // Warn logs a warning message func (l *Logger) Warn(format string, args ...interface{}) { l.zl.Warn().Msgf(format, args...) } // Error logs an error message func (l *Logger) Error(format string, args ...interface{}) { l.zl.Error().Msgf(format, args...) } // Fatal logs a fatal message and exits func (l *Logger) Fatal(format string, args ...interface{}) { l.zl.Fatal().Msgf(format, args...) } // SetLevel sets the log level func (l *Logger) SetLevel(level Level) { l.zl = l.zl.Level(level) } // GetLevel returns the current log level func (l *Logger) GetLevel() Level { return l.zl.GetLevel() } // ClientEvent logs client online/offline events as audit records. func (l *Logger) ClientEvent(event string, clientID uint64, ip string, extra ...string) { // clientID is the TCP connection id; the device's stable YAMA id arrives // as the "clientID" extra and is normalized to device_id so every audit // record uses the same field names (conn / device_id). fields := map[string]any{ "conn": clientID, "ip": ip, } for i := 0; i+1 < len(extra); i += 2 { key := extra[i] if key == "clientID" { key = "device_id" } fields[key] = extra[i+1] } l.Audit("device."+event, fields) } // Audit writes one security-audit record at info level. func (l *Logger) Audit(event string, fields map[string]any) { l.AuditAt(LevelInfo, event, fields) } // AuditWarn writes one security-audit record at warn level. Use it for // rejected / failed security-relevant operations. func (l *Logger) AuditWarn(event string, fields map[string]any) { l.AuditAt(LevelWarn, event, fields) } // AuditAt writes a security-audit record to the dedicated audit file (when // configured) and mirrors it to the main logger so operators tailing // server.log still see security events. event is a stable dotted name such // as "web.login.failure"; fields carry actor/role/ip/device_id/reason. // // Untrusted string values (usernames, User-Agent, Origin, device strings) // are sanitized: control characters are replaced so they cannot forge log // lines, and long values are truncated. func (l *Logger) AuditAt(level Level, event string, fields map[string]any) { if l == nil { return } clean := make(map[string]any, len(fields)+2) for k, v := range fields { clean[k] = sanitizeAuditValue(v) } clean["audit"] = true clean["event"] = event if l.auditEnabled { l.audit.WithLevel(level).Fields(clean).Msg("audit") } l.zl.WithLevel(level).Fields(clean).Msg("audit") } // maxAuditValueLen bounds untrusted log values (runes). A 1 MB WebSocket // frame could otherwise be reflected verbatim into every audit line. const maxAuditValueLen = 512 // maxAuditValueBytes is the pre-sanitization byte cap. It keeps a single // hostile value from allocating an unbounded rune slice before truncation. const maxAuditValueBytes = 4096 func sanitizeAuditValue(v any) any { switch x := v.(type) { case string: return sanitizeAuditString(x) case []byte: return sanitizeAuditString(string(x)) case error: return sanitizeAuditString(x.Error()) case fmt.Stringer: return sanitizeAuditString(x.String()) default: // Numeric/bool values are code-controlled and log-safe. return v } } func sanitizeAuditString(s string) string { if len(s) > maxAuditValueBytes { s = s[:maxAuditValueBytes] } s = strings.Map(func(r rune) rune { // C0 controls, DEL, and Unicode line/paragraph separators can be // used to forge or visually split log lines in console output. if r < 0x20 || r == 0x7f || r == 0x2028 || r == 0x2029 { return ' ' } return r }, s) if utf8.RuneCountInString(s) > maxAuditValueLen { r := []rune(s) s = string(r[:maxAuditValueLen]) } return s } // default global logger var defaultLogger = New(DefaultConfig()) // SetDefault sets the default global logger func SetDefault(l *Logger) { defaultLogger = l } // Default returns the default global logger func Default() *Logger { return defaultLogger } // Package-level convenience functions func Debug(format string, args ...interface{}) { defaultLogger.Debug(format, args...) } func Info(format string, args ...interface{}) { defaultLogger.Info(format, args...) } func Warn(format string, args ...interface{}) { defaultLogger.Warn(format, args...) } func Error(format string, args ...interface{}) { defaultLogger.Error(format, args...) } func Fatal(format string, args ...interface{}) { defaultLogger.Fatal(format, args...) }