Add a dedicated security audit trail to the Go server. Login attempts, authorization denials, WebSocket lifecycle, remote screen and terminal sessions, clipboard operations, device authentication and admin mutations now emit stable JSON-line events (audit=true, event=<name>) to a separate logs/audit.log, mirrored to server.log. The path is configurable through YAMA_AUDIT_LOG and the separate file can be switched off. Records never contain passcodes, password hashes, tokens or clipboard contents, and untrusted string fields are stripped of control characters and truncated so they cannot forge log lines. Expose the trail to administrators through a query_audit WebSocket command and an Audit Log page in the web UI. The page filters by time range, event (exact or prefix), actor, IP, device id, keyword and warning level, pages newest-first, and stays hidden unless the server advertises the auditLog capability, so older C++ servers degrade cleanly. Each query scans at most the newest 32 MB of the current file to bound latency, records itself as admin.audit.query, and tolerates torn or non-audit lines. Add unit tests for event emission and redaction, query filtering, paging, malformed lines, missing or disabled audit files, and handler authorization. Co-Authored-By: deepseek-flash
374 lines
9.4 KiB
Go
374 lines
9.4 KiB
Go
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=<name>, 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...)
|
|
}
|